"""SHAPE_TEMPLATE_RESEARCH — edge nella FORMA del prezzo: distanze alternative e template canonici. Due filoni, sull'harness ONESTO condiviso (shape_lab + explore_lab), netto-fee e OOS: 1. ANALOG con distanza di FORMA alternativa (DTW warping-invariant, correlazione/coseno) confrontata HEAD-TO-HEAD con l'euclidea a PARITA' di selettivita' (stessa libreria, stesso K, stessa soglia di accordo). DTW e' O(W^2): si usa una libreria SOTTOCAMPIONATA (uno start ogni `step` barre) + W ridotto + banda di Sakoe-Chiba. 2. TEMPLATE di forma canonici (doppio top/bottom, testa-spalle, V-reversal, salita/discesa lineare, U). A ogni i misuro la similarita' (correlazione di Pearson sulla finestra z-normalizzata) fra forma recente e ogni template; se supera soglia, entro a close[i] nella DIREZIONE ATTESA del template stimata SOLO sul passato (esito medio causale delle occorrenze gia' concluse di quel template), exit H barre o tp/sl ATR. VINCOLI anti-look-ahead (verificati esplicitamente): - la forma/match a i usa SOLO close fino a i (z-norm causale); - la direzione attesa di ogni template e la libreria analog usano SOLO occorrenze il cui esito a H barre e' gia' realizzato PRIMA di i (end + H <= i-1); - ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H. Netto fee 0.10% RT baseline + sweep fino a 0.20%. Leva 3x, pos 0.15. OOS ultimo 30%. Run riproducibile: uv run python scripts/analysis/shape_template_research.py DTW e' costoso: usa run_in_background per gli sweep larghi (vedi --sweep). """ from __future__ import annotations import sys import time from pathlib import Path import numpy as np from numpy.lib.stride_tricks import sliding_window_view PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from scripts.analysis.explore_lab import get_df, evaluate, robust, simulate, atr, ema, OOS_FRAC # noqa: E402 from scripts.analysis.shape_lab import znorm_windows, fwd_return # noqa: E402 RNG_SEED = 7 SUBC_ASSETS = ["BTC", "ETH", "SOL"] # ========================================================================================= # DISTANZE DI FORMA # ========================================================================================= def _euclid(q: np.ndarray, lib: np.ndarray) -> np.ndarray: """Distanza euclidea fra q (W,) e ogni riga di lib (M,W). Forme gia' z-normalizzate.""" return np.sqrt(((lib - q) ** 2).sum(axis=1)) def _corr_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray: """Distanza = 1 - correlazione di Pearson (q,lib gia' z-norm: corr = q.lib / W).""" # forme z-norm hanno media 0 std 1 -> dot/W e' la correlazione di Pearson corr = (lib @ q) / q.shape[0] return 1.0 - corr def _cosine_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray: """Distanza = 1 - coseno fra q e ogni riga di lib.""" qn = q / (np.linalg.norm(q) + 1e-12) ln = lib / (np.linalg.norm(lib, axis=1, keepdims=True) + 1e-12) return 1.0 - (ln @ qn) def _dtw_one(a: np.ndarray, b: np.ndarray, band: int) -> float: """DTW 1D con banda di Sakoe-Chiba (|i-j|<=band). a,b stessa lunghezza W.""" n = len(a) INF = 1e18 prev = np.full(n + 1, INF) prev[0] = 0.0 for i in range(1, n + 1): cur = np.full(n + 1, INF) jlo = max(1, i - band) jhi = min(n, i + band) ai = a[i - 1] for j in range(jlo, jhi + 1): cost = abs(ai - b[j - 1]) m = prev[j] if prev[j - 1] < m: m = prev[j - 1] if cur[j - 1] < m: m = cur[j - 1] cur[j] = cost + m prev = cur return float(prev[n]) def _dtw_dist(q: np.ndarray, lib: np.ndarray, band: int) -> np.ndarray: """DTW di q contro ogni riga di lib. O(M * W * band).""" out = np.empty(lib.shape[0]) for k in range(lib.shape[0]): out[k] = _dtw_one(q, lib[k], band) return out DIST_FUNCS = {"euclid": _euclid, "corr": _corr_dist, "cosine": _cosine_dist} # ========================================================================================= # FILONE 1 — ANALOG con distanza configurabile (libreria sottocampionata, causale) # ========================================================================================= def analog_dist_entries(df, dist="euclid", W=24, H=12, K=40, step=5, rebuild=500, min_lib=2000, agree=0.62, dtw_band=4, dtw_prefilter=200, decide_step=1, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]: """Analog kNN sulla FORMA con metrica `dist` ('euclid'|'corr'|'cosine'|'dtw'). Libreria SOTTOCAMPIONATA: si considerano solo finestre che terminano a indici multipli di `step` (riduce N e rende DTW trattabile). Causalita': la libreria a decisione i contiene solo finestre con end<=i-1-H (esito gia' realizzato). Ricostruita ogni `rebuild` barre. Stessa firma per tutte le metriche -> confronto head-to-head a parita' di selettivita' (stesso W,H,K,agree). DTW (costoso, O(W*band) per coppia in Python): si PREFILTRA con la correlazione ai `dtw_prefilter` candidati piu' simili, poi si fa DTW-rerank solo su quelli (approccio standard lower-bound/rerank). `decide_step`>1 valuta una barra ogni decide_step (non cambia la causalita', riduce solo il numero di query DTW). """ close = df["close"].values n = len(close) a = atr(df, 14) M, ends = znorm_windows(close, W) end_pos = {int(e): k for k, e in enumerate(ends)} fr = fwd_return(close, H) el = ema(close, ema_long) if trend_max is not None else None # candidati di libreria: solo end multipli di step (sottocampionamento causale fisso) base_ends = ends[(ends % step == 0)] entries: list[dict] = [] lib_M = None lib_idx = None next_rebuild = 0 for i in range(min_lib, n - 1): if i % decide_step != 0: continue if lib_M is None or i >= next_rebuild: elig = base_ends[(base_ends <= i - 1 - H) & (base_ends >= W - 1)] elig = elig[~np.isnan(fr[elig])] if len(elig) < max(K * 3, 200): next_rebuild = i + rebuild continue lib_M = M[[end_pos[int(e)] for e in elig]] lib_idx = elig next_rebuild = i + rebuild if lib_M is None: continue q = M[end_pos[i]] if not np.isfinite(q).all(): continue if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max: continue if dist == "dtw": # prefiltro corr (cheap, vettoriale) -> DTW-rerank solo sui top dtw_prefilter pre = _corr_dist(q, lib_M) npre = min(dtw_prefilter, len(lib_idx)) cand = np.argpartition(pre, npre - 1)[:npre] dd_cand = _dtw_dist(q, lib_M[cand], dtw_band) kk = min(K, len(cand)) sub = np.argpartition(dd_cand, kk - 1)[:kk] nn = cand[sub] 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)) if frac < agree: 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) continue dd = DIST_FUNCS[dist](q, lib_M) kk = min(K, len(lib_idx)) nn = np.argpartition(dd, kk - 1)[:kk] 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)) if frac < agree: 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 # ========================================================================================= # FILONE 2 — TEMPLATE di forma canonici # ========================================================================================= def make_templates(W: int) -> dict[str, np.ndarray]: """Template parametrici z-normalizzati di lunghezza W (forma pura, no scala/livello). Sono solo descrittori di FORMA recente (gli ultimi W close). La direzione attesa NON e' decisa a priori: viene stimata causalmente sul passato (vedi template_entries). """ t = np.linspace(0, 1, W) s = 0.012 # ampiezza gaussiana scalata sulla finestra (W-indipendente in t in [0,1]) g = lambda c: np.exp(-((t - c) ** 2) / s) raw = { # estremi di reversione a DOPPIO picco (due massimi / minimi simmetrici) "double_top": g(0.25) + g(0.75), # M: due cime "double_bottom": -(g(0.25) + g(0.75)), # W: due fondi # testa-spalle: spalla-testa-spalla (centro piu' alto) "head_shoulders": g(0.2) + 1.7 * g(0.5) + g(0.8), "inv_head_shoulders": -(g(0.2) + 1.7 * g(0.5) + g(0.8)), # singola reversione "v_bottom": np.abs(t - 0.5), "inv_v_top": -np.abs(t - 0.5), "u_bottom": (t - 0.5) ** 2, "arch_top": -((t - 0.5) ** 2), # trend lineari "ramp_up": t, "ramp_down": -t, } out = {} for k, v in raw.items(): v = np.asarray(v, dtype=float) sd = v.std() out[k] = (v - v.mean()) / (sd if sd > 0 else 1.0) return out def template_entries(df, W=24, H=12, corr_min=0.85, dir_min=0.10, min_lib=2000, rebuild=300, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200, templates=None) -> list[dict]: """Entries da match con template canonici, DIREZIONE stimata SOLO sul passato. A ogni i, per ogni template calcolo la correlazione di Pearson fra la forma recente z-norm (close[i-W+1..i]) e il template. Prendo il template a correlazione massima; se >= corr_min lo considero "attivo". La DIREZIONE in cui entrare e' il segno del rendimento forward MEDIO storico delle occorrenze gia' concluse (end+H<=i-1) di quel template (stesso criterio di match), purche' |media| in barre-equivalenti superi dir_min*media_atr-ish -> qui dir_min e' una soglia sulla |media forward| relativa (frazione). NIENTE direzione a priori: se il passato non e' coerente (occorrenze= W - 1] lib_M = M[[end_pos[int(e)] for e in lib_ends]] # (L, W) corr_mat = (lib_M @ T.T) / W # (L, NT) best_tpl = np.argmax(corr_mat, axis=1) best_corr = corr_mat[np.arange(len(lib_ends)), best_tpl] lib_fr = fr[lib_ends] lib_end_arr = lib_ends entries: list[dict] = [] # cache direzione per template, ricostruita ogni rebuild barre dir_cache: dict[int, int] = {} next_rebuild = 0 for i in range(min_lib, n - 1): q = M[end_pos[i]] if not np.isfinite(q).all(): continue cq = (T @ q) / W # corr con ogni template bt = int(np.argmax(cq)) if cq[bt] < corr_min: continue if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max: continue # direzione attesa: media forward causale delle occorrenze concluse dello stesso template if i >= next_rebuild: dir_cache = {} next_rebuild = i + rebuild if bt not in dir_cache: mask = (lib_end_arr <= i - 1 - H) & (best_tpl == bt) & (best_corr >= corr_min) & (~np.isnan(lib_fr)) outs = lib_fr[mask] if len(outs) < 30: dir_cache[bt] = 0 else: m = float(outs.mean()) # soglia: |media| forward deve superare dir_min volte la std forward (edge vs rumore) sd = float(outs.std()) + 1e-12 dir_cache[bt] = (1 if m > 0 else -1) if abs(m) / sd >= dir_min else 0 d = dir_cache[bt] if d == 0: 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 # ========================================================================================= # CHECK CAUSALITA' espliciti # ========================================================================================= def check_causality_analog(df, **kw) -> bool: """Le entries non devono cambiare se perturbo il FUTURO oltre l'ultima barra usata. Tronco il df a una certa lunghezza L e verifico che le entries con i bool: L = int(len(df) * 0.55) H = kw.get("H", 12) full = template_entries(df, **kw) trunc = template_entries(df.iloc[:L].reset_index(drop=True), **kw) horizon = L - H - 2 f = {e["i"]: e["d"] for e in full if e["i"] < horizon} t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon} ok = (f == t) print(f" causalita' template: {'OK' if ok else 'VIOLATO'} " f"({len(f)} entries confrontate <{horizon})") return ok # ========================================================================================= # RUN # ========================================================================================= def run_head_to_head(assets=SUBC_ASSETS, W=16, H=12, K=40, step=6, agree=0.62, decide_step=4, dtw_prefilter=120): """Confronto HEAD-TO-HEAD delle metriche di forma a PARITA' di selettivita'. Tutte le metriche valutano le STESSE barre-decisione (decide_step) con lo STESSO W/H/K/agree: l'unica variabile e' la distanza. decide_step>1 serve a rendere DTW trattabile (pura Python ~9ms/query); applicato a tutte per equita'. """ print("=" * 100) print(f" FILONE 1 — ANALOG head-to-head metriche (W{W} H{H} K{K} step{step} " f"agree{agree} decide_step{decide_step}) | netto fee, OOS") print("=" * 100) results = {} for asset in assets: df = get_df(asset, "1h") print(f"\n --- {asset} 1h (n={len(df)}) ---", flush=True) for dist in ["euclid", "corr", "cosine", "dtw"]: t0 = time.time() ents = analog_dist_entries(df, dist=dist, W=W, H=H, K=K, step=step, agree=agree, dtw_band=max(2, W // 5), dtw_prefilter=dtw_prefilter, decide_step=decide_step) dt = time.time() - t0 res = evaluate(f"{dist:<7s}", ents, df) results[(asset, dist)] = res print(f" ^ time={dt:>5.1f}s robust={'YES' if robust(res) else 'no '}", flush=True) return results def run_templates(assets=SUBC_ASSETS, W=20, H=12, corr_min=0.85, dir_min=0.10): print("=" * 100) print(f" FILONE 2 — TEMPLATE canonici (W{W} H{H} corr>={corr_min} dir>={dir_min}) | netto fee, OOS") print("=" * 100) results = {} for asset in assets: df = get_df(asset, "1h") print(f"\n --- {asset} 1h (n={len(df)}) ---") for cm in [0.80, 0.85, 0.90]: ents = template_entries(df, W=W, H=H, corr_min=cm, dir_min=dir_min) res = evaluate(f"corr_min={cm}", ents, df) results[(asset, cm)] = res print(f" ^ robust={'YES' if robust(res) else 'no '}") return results def run_sweep(): """Sweep largo (lento per via di DTW). Usa run_in_background.""" print("=" * 100) print(" SWEEP LARGO — analog griglia W/H/K/step x metriche + template griglia") print("=" * 100) for W in [16, 20, 28]: for H in [8, 12, 24]: print(f"\n##### W={W} H={H} #####") run_head_to_head(W=W, H=H, K=40, step=6, agree=0.62) for W in [16, 20, 28]: for H in [8, 12, 24]: print(f"\n##### TEMPLATE W={W} H={H} #####") run_templates(W=W, H=H, corr_min=0.85, dir_min=0.10) def run(): np.random.seed(RNG_SEED) print("#" * 100) print(" SHAPE_TEMPLATE_RESEARCH — distanze di forma alternative + template canonici") print("#" * 100) # 1) check causalita' espliciti print("\n[CAUSALITA']") dfb = get_df("BTC", "1h") check_causality_analog(dfb, dist="euclid", W=20, H=12, K=40, step=6, min_lib=2000) check_causality_analog(dfb, dist="dtw", W=16, H=12, K=40, step=8, min_lib=2000, dtw_band=3, decide_step=20) check_causality_template(dfb, W=20, H=12, corr_min=0.85) # 2) head-to-head metriche print() run_head_to_head() # 3) template print() run_templates() if __name__ == "__main__": if "--sweep" in sys.argv: run_sweep() elif "--templates" in sys.argv: run_templates() elif "--h2h" in sys.argv: run_head_to_head() else: run()