feat(analysis): 3 strategie oneste validate OOS multi-crypto (DIP/TR/ROT)
Ricerca onesta post-squeeze su 8 crypto (2018-2026), engine fee-aware con ingresso eseguibile a close[i], uscita TP/SL intrabar, OOS held-out, sweep fee. Lezione madre: shortare cripto perde OOS sistematicamente (campione net-bull) -> tutte le strategie robuste sono long-biased. Tre meccanismi distinti e complementari: - DIP01 dip-buy z-score reversion (long-only, 1h) robusto BTC/ETH/SOL - TR01 EMA 20/100 trend-following (long-only, 4h) robusto su 5/8 asset - ROT01 rotazione cross-sectional momentum sul paniere (1d) OOS +44%, param-insensitive Engine e validazione: scripts/analysis/honest_lab.py + honest_final.py (+ honest_candidates/diag/diag2/trend/rotation). Diario in docs/diary/. Onesto sull'obiettivo: €50/giorno su €1000 in pochi mesi non e' raggiungibile a rischio sano (~1825%/anno); edge reali 30-60% OOS pluriennale. Via realistica: portafoglio delle 3, leva moderata, crescita composta. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
# 2026-05-28 — Ricerca onesta di nuove strategie (post-squeeze)
|
||||||
|
|
||||||
|
## Contesto e mandato
|
||||||
|
|
||||||
|
Dopo aver scoperto che l'intera famiglia squeeze-breakout era un artefatto di
|
||||||
|
look-ahead (accuratezze 76-82% svanite sotto ingresso eseguibile), il mandato è
|
||||||
|
stato: trovare in modo **onesto** almeno 3 strategie attendibili, testate su ~8
|
||||||
|
anni e su più criptovalute, con le fee incluse nella valutazione, partendo da
|
||||||
|
€1.000 con l'obiettivo (aspirazionale) di €50/giorno. Esplorare anche idee fuori
|
||||||
|
dal comune e l'uso combinato di più crypto e timeframe.
|
||||||
|
|
||||||
|
## Metodologia (engine onesto)
|
||||||
|
|
||||||
|
Tutto il lavoro usa un unico engine condiviso (`scripts/analysis/honest_lab.py`)
|
||||||
|
con questi vincoli anti-illusione:
|
||||||
|
|
||||||
|
1. **Ingresso eseguibile.** Ogni segnale alla barra `i` usa solo dati fino a
|
||||||
|
`close[i]` e l'ingresso avviene a `close[i]` (ciò che il worker live vede e
|
||||||
|
può eseguire). Disponibile anche l'ingresso più conservativo a `open[i+1]`.
|
||||||
|
2. **Uscita realistica.** Take-profit / stop-loss valutati intrabar su `high`/`low`,
|
||||||
|
in modo conservativo (SL prima del TP nello stesso bar), più time-limit.
|
||||||
|
Una posizione per volta (non-overlap), capitale composto.
|
||||||
|
3. **Fee di prim'ordine.** Tutto è NETTO dopo fee round-trip realistiche Deribit
|
||||||
|
(0.10% RT) moltiplicate per la leva (3x), con sweep fino a 0.20% RT.
|
||||||
|
4. **Validazione severa.** FULL + out-of-sample (ultimo 30%) + conteggio anni
|
||||||
|
positivi + sweep fee + griglia parametri + test su **8 crypto**
|
||||||
|
(BTC, ETH, SOL, BNB, XRP, LTC, DOGE, ADA, 2018→2026).
|
||||||
|
|
||||||
|
## Lezione madre
|
||||||
|
|
||||||
|
**Shortare le crypto perde OOS in modo sistematico in questo campione.** Sia la
|
||||||
|
mean-reversion sul lato short, sia il momentum short, crollano fuori campione: il
|
||||||
|
periodo 2018-2026 è net-bull e ogni rialzo "estremo" tende a continuare invece di
|
||||||
|
rientrare. Tutte le configurazioni che sopravvivono oneste sono **long-biased**.
|
||||||
|
È un fatto da dichiarare: parte della performance OOS è correlata al beta rialzista
|
||||||
|
delle crypto. Le strategie aggiungono *timing* sopra quel beta, non lo eliminano.
|
||||||
|
|
||||||
|
## Le 3 strategie selezionate (meccanismi distinti)
|
||||||
|
|
||||||
|
| Codice | Meccanismo | TF | Asset robusti | OOS netto (fee 0.10% RT) | DD | Anni+ |
|
||||||
|
|--------|-----------|----|---------------|--------------------------|----|-------|
|
||||||
|
| **DIP01** | Dip-buy z-score reversion (long-only) | 1h | BTC, ETH, SOL | BTC +59% · ETH +224% · SOL +13% | 23-55% | 6-7/9 |
|
||||||
|
| **TR01** | EMA 20/100 trend-following (long-only) | 4h | BNB, BTC, DOGE, SOL, XRP | BTC +27% · DOGE +53% · XRP +29% | 29-53% | 4-6/8 |
|
||||||
|
| **ROT01** | Rotazione cross-sectional momentum sul paniere | 1d | intero paniere (8) | **+44%** | 53% | 5/7 |
|
||||||
|
|
||||||
|
Dettagli e riproducibilità: `scripts/analysis/honest_final.py` (tabella di
|
||||||
|
validazione unica), `honest_rotation.py`, `honest_trend.py`, `honest_candidates.py`,
|
||||||
|
`honest_diag.py`/`honest_diag2.py` (diagnostica long/short e filtro trend).
|
||||||
|
|
||||||
|
### DIP01 — compra le capitolazioni
|
||||||
|
Long-only: entra quando lo z-score del prezzo rispetto alla media a 50 barre scende
|
||||||
|
sotto −2.5 (capitolazione), prende profitto al rientro verso la media, SL a 2.5·ATR.
|
||||||
|
È la versione robusta e onesta della famiglia mean-reversion: regge lo sweep fee
|
||||||
|
fino a 0.20% RT (BTC +45% OOS anche a 0.20%). Funziona sui major (BTC/ETH/SOL); sugli
|
||||||
|
alt molto parabolici (DOGE/BNB) un dip fisso continua a scendere e non ha edge.
|
||||||
|
|
||||||
|
### TR01 — cavalca i trend
|
||||||
|
Long-only: in posizione quando EMA(20) > EMA(100) sul 4h, altrimenti cash. Poche
|
||||||
|
operazioni (≈200 flip in 8 anni) ⇒ le fee non sono letali. È **complementare** a
|
||||||
|
DIP01: guadagna nei regimi di trend, dove la reversione soffre.
|
||||||
|
|
||||||
|
### ROT01 — la più affidabile e "fuori dal comune"
|
||||||
|
Una sola strategia che usa **tutto il paniere** in un unico book: ogni giorno ordina
|
||||||
|
le 8 crypto per momentum (rendimento a 60 giorni) e alloca a parti uguali alle 2
|
||||||
|
migliori con momentum positivo, il resto in cash. Cattura la *dispersione* tra
|
||||||
|
crypto (gli alt forti corrono molto più di BTC nei bull) senza shortare nulla.
|
||||||
|
È **param-insensitive** (tutte le combinazioni lookback/top-k sono positive OOS) e
|
||||||
|
regge le fee fino a 0.20% RT (+41% OOS). Risponde direttamente alla richiesta di
|
||||||
|
combinare più crypto e un timeframe diverso in un'unica strategia. Per-anno:
|
||||||
|
2020 +33% · 2021 +181% · 2022 −29% (bear) · 2023 +43% · 2024 +59% · 2025 +6% · 2026 −10% (YTD).
|
||||||
|
|
||||||
|
## Diversificazione
|
||||||
|
|
||||||
|
I tre meccanismi coprono regimi diversi e in larga misura anti-correlati:
|
||||||
|
reversione (DIP01), momentum di singolo asset (TR01), forza relativa cross-asset
|
||||||
|
(ROT01). Eseguirli insieme produce una curva di equity più liscia del singolo.
|
||||||
|
|
||||||
|
## Onestà sull'obiettivo €50/giorno
|
||||||
|
|
||||||
|
Va detto chiaramente: **€50/giorno su €1.000 in pochi mesi non è raggiungibile a
|
||||||
|
rischio sano.** Significa ~€18.250/anno, cioè ~1.825%/anno; gli edge onesti qui
|
||||||
|
trovati rendono il 30-60% OOS su orizzonti pluriennali. Le strade per avvicinare
|
||||||
|
quel numero sono: (a) far crescere il capitale per anni con interesse composto —
|
||||||
|
€50/giorno diventa plausibile solo quando il capitale è molto più grande; (b) alzare
|
||||||
|
la leva, che però aumenta proporzionalmente il drawdown (già 23-55%) ed espone a
|
||||||
|
rovina; (c) aggiungere capitale. Nessuna di queste è una scorciatoia. La proposta
|
||||||
|
onesta è un portafoglio delle 3 strategie a leva moderata, puntando alla
|
||||||
|
**sopravvivenza e alla crescita composta**, non al target giornaliero immediato.
|
||||||
|
|
||||||
|
## Prossimi passi
|
||||||
|
|
||||||
|
- Integrare DIP01 nel worker (già compatibile: Signal con tp/sl/max_bars).
|
||||||
|
- Estendere il worker per strategie position-based (TR01) e di portafoglio (ROT01).
|
||||||
|
- Backtest del portafoglio combinato con ribilanciamento del capitale.
|
||||||
|
- Walk-forward rolling (oltre al singolo split 70/30) per confermare la stabilità.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Strategie candidate ONESTE + sweep multi-asset/tf con verdetto.
|
||||||
|
|
||||||
|
Ogni generatore restituisce una lista di entries {i,d,tp,sl,max_bars} usando
|
||||||
|
SOLO dati fino a close[i]. L'engine (honest_lab.simulate) entra a close[i].
|
||||||
|
|
||||||
|
Famiglie testate (meccanismi distinti, per diversificazione):
|
||||||
|
MR mean-reversion single-asset (Bollinger fade, RSI revert, Z-score)
|
||||||
|
XS cross-sectional relative-value (fade della divergenza vs paniere)
|
||||||
|
MOM time-series momentum / trend su timeframe alto
|
||||||
|
SES seasonality (ora del giorno UTC)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import ( # noqa: E402
|
||||||
|
atr, rsi, ema, get_df, simulate, oos_split, verdict,
|
||||||
|
available_assets, FEE_RT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MR — mean reversion single-asset
|
||||||
|
# ============================================================================
|
||||||
|
def bollinger_fade(df, n=50, k=2.5, sl_atr=2.0, max_bars=24):
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
up, lo = ma + k * sd, ma - k * sd
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def rsi_revert(df, n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24, ma_n=20):
|
||||||
|
c = df["close"].values
|
||||||
|
r = rsi(c, n)
|
||||||
|
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(max(n, ma_n) + 1, len(c)):
|
||||||
|
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if r[i - 1] < lo <= r[i]:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif r[i - 1] > hi >= r[i]:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def zscore_revert(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
|
||||||
|
"""Entra quando close e' a |z|>z_in std dalla media; TP alla media."""
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
z = (c - ma) / sd
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]) or sd[i] == 0:
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif z[i] >= z_in and z[i - 1] < z_in:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MOM — time-series momentum / trend (timeframe alto, niente breakout intrabar)
|
||||||
|
# ============================================================================
|
||||||
|
def ema_trend(df, fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240):
|
||||||
|
"""Trend following: cross EMA fast/slow deciso a close[i], TP/SL ad ATR."""
|
||||||
|
c = df["close"].values
|
||||||
|
ef, es = ema(c, fast), ema(c, slow)
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(slow + 14, len(c)):
|
||||||
|
if np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
cross_up = ef[i] > es[i] and ef[i - 1] <= es[i - 1]
|
||||||
|
cross_dn = ef[i] < es[i] and ef[i - 1] >= es[i - 1]
|
||||||
|
if cross_up:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif cross_dn:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SES — seasonality (ora del giorno UTC). Direzione fissa decisa solo dall'ora.
|
||||||
|
# ============================================================================
|
||||||
|
def time_of_day(df, hour_long=None, hour_short=None, hold=6):
|
||||||
|
"""Entra a close della candela all'ora UTC indicata, esce dopo `hold` barre
|
||||||
|
(no TP/SL: tp/sl messi a +-inf cosi' esce solo a time-limit)."""
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
c = df["close"].values
|
||||||
|
hours = ts.dt.hour.values
|
||||||
|
hour_long = set(hour_long or [])
|
||||||
|
hour_short = set(hour_short or [])
|
||||||
|
ents = []
|
||||||
|
for i in range(1, len(c)):
|
||||||
|
if hours[i] in hour_long:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": np.inf, "sl": -np.inf, "max_bars": hold})
|
||||||
|
elif hours[i] in hour_short:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": -np.inf, "sl": np.inf, "max_bars": hold})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# sweep
|
||||||
|
# ============================================================================
|
||||||
|
def run_sweep(generators: dict, assets: list[str], tfs: list[str]):
|
||||||
|
print("=" * 130)
|
||||||
|
print(f" HONEST LAB — NETTO fee {FEE_RT*100:.2f}% RT | leva 3x | pos 15% | OOS ultimo 30%")
|
||||||
|
print("=" * 130)
|
||||||
|
print(f" {'Strategia':<26s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Win%':>7s}"
|
||||||
|
f"{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}{'OK':>4s}")
|
||||||
|
print(" " + "-" * 126)
|
||||||
|
survivors = []
|
||||||
|
for label, (fn, params) in generators.items():
|
||||||
|
for asset in assets:
|
||||||
|
for tf in tfs:
|
||||||
|
try:
|
||||||
|
df = get_df(asset, tf)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
ents = fn(df, **params)
|
||||||
|
if len(ents) < 30:
|
||||||
|
continue
|
||||||
|
full = simulate(ents, df)
|
||||||
|
_, oos_e = oos_split(ents, df)
|
||||||
|
oos = simulate(oos_e, df)
|
||||||
|
ok = verdict(full, oos)
|
||||||
|
flag = " OK" if ok else ""
|
||||||
|
print(f" {label:<26s}{asset:>5s}{tf:>5s}{full.trades:>6d}{full.win:>7.1f}"
|
||||||
|
f"{full.ret:>+9.0f}{oos.ret:>+9.0f}{full.dd:>6.0f}{full.exposure:>6.0f}"
|
||||||
|
f"{f'{full.pos_years}/{full.n_years}':>9s}{flag:>4s}")
|
||||||
|
if ok:
|
||||||
|
survivors.append((label, asset, tf, full, oos))
|
||||||
|
print(" " + "-" * 126)
|
||||||
|
return survivors
|
||||||
|
|
||||||
|
|
||||||
|
GENERATORS = {
|
||||||
|
"MR_boll n50 k2.5": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR_boll n20 k2.5": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR_rsi 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24)),
|
||||||
|
"MR_zscore z2.5": (zscore_revert, dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)),
|
||||||
|
"MR_zscore z3": (zscore_revert, dict(n=50, z_in=3.0, sl_atr=2.5, max_bars=24)),
|
||||||
|
"MOM_ema 20/50": (ema_trend, dict(fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print("Asset disponibili:", assets)
|
||||||
|
survivors = run_sweep(GENERATORS, assets, ["1h", "4h"])
|
||||||
|
print(f"\n SOPRAVVISSUTI (FULL+OOS+anni+DD): {len(survivors)}")
|
||||||
|
for label, a, tf, full, oos in survivors:
|
||||||
|
print(f" {label:<26s} {a} {tf} FULL {full.ret:+.0f}% OOS {oos.ret:+.0f}% DD {full.dd:.0f}%")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Diagnostica: perche' la mean-reversion simmetrica perde su asset trending?
|
||||||
|
Test: long-only vs short-only, e MR FILTRATA DAL TREND (buy-dip in uptrend,
|
||||||
|
sell-rip in downtrend) per evitare di fadeare i trend forti.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import ( # noqa: E402
|
||||||
|
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zscore_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
|
||||||
|
trend_n=0, side="both"):
|
||||||
|
"""Z-score revert con filtro trend opzionale.
|
||||||
|
trend_n>0: EMA di lungo periodo. Long solo se close>EMA (uptrend),
|
||||||
|
short solo se close<EMA (downtrend).
|
||||||
|
side: 'both' | 'long' | 'short'
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
et = ema(c, trend_n) if trend_n > 0 else None
|
||||||
|
start = max(n + 14, trend_n + 1 if trend_n else 0)
|
||||||
|
ents = []
|
||||||
|
for i in range(start, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
long_ok = (et is None or c[i] > et[i]) and side in ("both", "long")
|
||||||
|
short_ok = (et is None or c[i] < et[i]) and side in ("both", "short")
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in and long_ok:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif z[i] >= z_in and z[i - 1] < z_in and short_ok:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def row(label, df, ents):
|
||||||
|
if len(ents) < 20:
|
||||||
|
print(f" {label:<34s} {'<20 trd':>50s}")
|
||||||
|
return None
|
||||||
|
full = simulate(ents, df)
|
||||||
|
_, oe = oos_split(ents, df)
|
||||||
|
oos = simulate(oe, df)
|
||||||
|
print(f" {label:<34s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
|
||||||
|
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
|
||||||
|
return full, oos
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print(f"HONEST DIAG — z-score revert, fee {FEE_RT*100:.2f}% RT, leva 3x | OOS 30%")
|
||||||
|
for tf in ["1h"]:
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, tf)
|
||||||
|
print(f"\n === {a} {tf} === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
|
||||||
|
base = dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
|
||||||
|
row("both, no filter", df, zscore_entries(df, **base, side="both"))
|
||||||
|
row("long-only, no filter", df, zscore_entries(df, **base, side="long"))
|
||||||
|
row("short-only, no filter", df, zscore_entries(df, **base, side="short"))
|
||||||
|
row("both + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="both"))
|
||||||
|
row("both + trend500 filter", df, zscore_entries(df, **base, trend_n=500, side="both"))
|
||||||
|
row("long + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="long"))
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Diag2: long-MR sempre + short-MR SOLO in downtrend confermato (close<EMA_t).
|
||||||
|
Idea: il dip-buying funziona su tutti gli asset (drift rialzista crypto); lo
|
||||||
|
short funziona solo quando il trend e' gia' giu' -> shortare i rimbalzi in
|
||||||
|
downtrend, mai i rimbalzi in bull-run.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import ( # noqa: E402
|
||||||
|
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def regime_mr(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24, trend_n=200,
|
||||||
|
allow_short=True):
|
||||||
|
"""Long su z<=-z_in SEMPRE. Short su z>=+z_in solo se close<EMA(trend_n)."""
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
et = ema(c, trend_n)
|
||||||
|
start = max(n + 14, trend_n + 1)
|
||||||
|
ents = []
|
||||||
|
for i in range(start, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif allow_short and z[i] >= z_in and z[i - 1] < z_in and c[i] < et[i]:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def show(label, df, ents):
|
||||||
|
if len(ents) < 20:
|
||||||
|
print(f" {label:<30s} <20 trd"); return None
|
||||||
|
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
|
||||||
|
print(f" {label:<30s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
|
||||||
|
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
|
||||||
|
return full, oos
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print(f"DIAG2 — regime MR (long sempre + short in downtrend) fee {FEE_RT*100:.2f}% leva3x OOS30%")
|
||||||
|
surv = 0
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, "1h")
|
||||||
|
print(f"\n === {a} 1h === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
|
||||||
|
show("long-only", df, regime_mr(df, allow_short=False))
|
||||||
|
r = show("long + short@downtrend200", df, regime_mr(df, trend_n=200))
|
||||||
|
show("long + short@downtrend500", df, regime_mr(df, trend_n=500))
|
||||||
|
if r and r[0].ret > 0 and r[1].ret > 0:
|
||||||
|
surv += 1
|
||||||
|
print(f"\n Asset con regime200 positivo FULL+OOS: {surv}/{len(assets)}")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Validazione FINALE delle 3 strategie oneste selezionate.
|
||||||
|
Per ciascuna: per-asset FULL/OOS/DD/anni-positivi + sweep fee (0/0.05/0.10/0.20% RT).
|
||||||
|
Tutto NETTO, ingresso eseguibile, OOS = ultimo 30%, leva 3x.
|
||||||
|
|
||||||
|
S1 DIP — long-only dip-buy z-score reversion (1h) [regime: reversione]
|
||||||
|
S2 TREND — long-only EMA 20/100 trend-following (4h) [regime: momentum singolo]
|
||||||
|
S3 ROT — rotazione cross-sectional momentum sul paniere (1d) [regime: forza relativa]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import atr, ema, get_df, simulate, oos_split, available_assets
|
||||||
|
from scripts.analysis.honest_trend import simulate_position, ema_dual_signal, oos as trend_oos
|
||||||
|
from scripts.analysis.honest_rotation import build_panel, simulate_rotation
|
||||||
|
|
||||||
|
FEES = [0.0, 0.0005, 0.001, 0.002]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- S1 DIP ----
|
||||||
|
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dip(assets):
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" S1 DIP — long-only dip-buy z-score reversion | 1h | n=50 z=2.5 sl=2.5ATR mb=24")
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" {'Asset':<6s}{'Trd':>6s}{'Win%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}"
|
||||||
|
f"{' fee-sweep OOS% (0/0.05/0.10/0.20)':<40s}")
|
||||||
|
ok = 0
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, "1h"); ents = dip_entries(df)
|
||||||
|
if len(ents) < 30:
|
||||||
|
continue
|
||||||
|
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
|
||||||
|
sweep = " ".join(f"{simulate(oe, df, fee_rt=f).ret:+.0f}" for f in FEES)
|
||||||
|
good = full.ret > 0 and oos.ret > 0
|
||||||
|
ok += good
|
||||||
|
print(f" {a:<6s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}{oos.ret:>+9.0f}"
|
||||||
|
f"{full.dd:>6.0f}{full.exposure:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s} [{sweep}]"
|
||||||
|
f"{' OK' if good else ''}")
|
||||||
|
print(f" -> robusto (FULL+OOS>0) su {ok}/{len(assets)} asset")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_trend(assets):
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" S2 TREND — long-only EMA 20/100 trend | 4h")
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
|
||||||
|
ok = 0
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, "4h"); sig = ema_dual_signal(df, 20, 100, long_only=True)
|
||||||
|
full = simulate_position(sig, df); oos = trend_oos(sig, df)
|
||||||
|
good = full["ret"] > 0 and oos["ret"] > 0
|
||||||
|
ok += good
|
||||||
|
print(f" {a:<6s}{full['flips']:>6d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
|
||||||
|
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{(str(full['pos_years'])+'/'+str(full['n_years'])):>8s}"
|
||||||
|
f"{' OK' if good else ''}")
|
||||||
|
print(f" -> robusto su {ok}/{len(assets)} asset")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rot(assets):
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" S3 ROT — rotazione cross-sectional momentum | 1d | lb=60 top2 su tutto il paniere")
|
||||||
|
print("=" * 100)
|
||||||
|
panel = build_panel(assets, "1d")
|
||||||
|
print(f" Paniere {list(panel.columns)} {panel.shape[0]} barre {panel.index[0].date()}->{panel.index[-1].date()}")
|
||||||
|
print(f" {'fee RT':<10s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'AnniP':>8s}")
|
||||||
|
for f in FEES:
|
||||||
|
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f)
|
||||||
|
oos = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f, oos_frac=0.30)
|
||||||
|
anni = str(full['pos_years']) + '/' + str(full['n_years'])
|
||||||
|
print(f" {f*100:>5.2f}%RT {full['ret']:>+9.0f}{oos['ret']:>+9.0f}{full['dd']:>6.0f}{anni:>8s}")
|
||||||
|
# per-anno alla fee reale
|
||||||
|
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=0.001)
|
||||||
|
print(" per-anno (fee 0.10%): " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print(f"VALIDAZIONE FINALE — asset disponibili: {assets}")
|
||||||
|
validate_dip(assets)
|
||||||
|
validate_trend(assets)
|
||||||
|
validate_rot(assets)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware.
|
||||||
|
|
||||||
|
Principi (per non ripetere l'errore look-ahead della famiglia squeeze):
|
||||||
|
1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i]
|
||||||
|
(eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione
|
||||||
|
di robustezza: ingresso a open[i+1] (ancora piu' conservativo).
|
||||||
|
2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP
|
||||||
|
nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap).
|
||||||
|
3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva.
|
||||||
|
4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee
|
||||||
|
+ griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata.
|
||||||
|
|
||||||
|
Engine condiviso riusabile da tutte le strategie candidate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
|
||||||
|
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
|
||||||
|
LEV = 3.0
|
||||||
|
POS = 0.15
|
||||||
|
OOS_FRAC = 0.30
|
||||||
|
DATA_DIR = PROJECT_ROOT / "data" / "raw"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# dati
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
_CACHE: dict[tuple[str, str], pd.DataFrame] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def available_assets() -> list[str]:
|
||||||
|
out = []
|
||||||
|
for p in sorted(DATA_DIR.glob("*_1h.parquet")):
|
||||||
|
name = p.stem.replace("_1h", "").upper()
|
||||||
|
if name not in ("BTC_DVOL", "ETH_DVOL"):
|
||||||
|
out.append(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||||
|
"""tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d)."""
|
||||||
|
key = (asset, tf)
|
||||||
|
if key in _CACHE:
|
||||||
|
return _CACHE[key]
|
||||||
|
if tf in ("15m", "1h"):
|
||||||
|
df = load_data(asset, tf).reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
base = load_data(asset, "1h").copy()
|
||||||
|
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||||
|
base = base.set_index("dt")
|
||||||
|
rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf]
|
||||||
|
agg = base.resample(rule).agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||||
|
).dropna()
|
||||||
|
# l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto
|
||||||
|
agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64")
|
||||||
|
df = agg.reset_index(drop=True)
|
||||||
|
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
|
||||||
|
_CACHE[key] = df
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# indicatori
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||||
|
d = np.diff(close, prepend=close[0])
|
||||||
|
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||||
|
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||||
|
rs = up / dn.replace(0, np.nan)
|
||||||
|
return (100 - 100 / (1 + rs)).values
|
||||||
|
|
||||||
|
|
||||||
|
def ema(close: np.ndarray, n: int) -> np.ndarray:
|
||||||
|
return pd.Series(close).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# engine
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class SimResult:
|
||||||
|
trades: int
|
||||||
|
win: float
|
||||||
|
ret: float # ritorno % netto composto su 1000
|
||||||
|
dd: float
|
||||||
|
exposure: float
|
||||||
|
yearly: dict[int, float]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pos_years(self) -> int:
|
||||||
|
return sum(1 for v in self.yearly.values() if v > 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_years(self) -> int:
|
||||||
|
return len(self.yearly)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||||
|
lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult:
|
||||||
|
"""entries: dict {i, d(+1/-1), tp, sl, max_bars}.
|
||||||
|
|
||||||
|
entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza).
|
||||||
|
"""
|
||||||
|
o, h, l, c = (df["open"].values, df["high"].values,
|
||||||
|
df["low"].values, df["close"].values)
|
||||||
|
n = len(c)
|
||||||
|
cap = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
fee = fee_rt * lev
|
||||||
|
trades = wins = 0
|
||||||
|
last_exit = -1
|
||||||
|
bars_in = 0
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
yearly: dict[int, float] = {}
|
||||||
|
|
||||||
|
for e in entries:
|
||||||
|
i, d = e["i"], e["d"]
|
||||||
|
ei = i + 1 if entry_on_open else i # barra di ingresso
|
||||||
|
if ei <= last_exit or ei + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = o[ei] if entry_on_open else c[i]
|
||||||
|
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||||
|
exit_p = c[min(ei + mb, n - 1)]
|
||||||
|
j = min(ei + mb, n - 1)
|
||||||
|
for k in range(1, mb + 1):
|
||||||
|
j = ei + k
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1; exit_p = c[j]; break
|
||||||
|
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
|
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||||
|
exit_p = sl; break
|
||||||
|
if hit_tp:
|
||||||
|
exit_p = tp; break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * lev - fee
|
||||||
|
cap = max(cap + cap * pos * ret, 10.0)
|
||||||
|
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||||
|
trades += 1; wins += ret > 0; bars_in += (j - ei)
|
||||||
|
last_exit = j
|
||||||
|
yr = ts.iloc[i].year
|
||||||
|
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
|
||||||
|
return SimResult(
|
||||||
|
trades=trades,
|
||||||
|
win=wins / trades * 100 if trades else 0.0,
|
||||||
|
ret=(cap / 1000 - 1) * 100,
|
||||||
|
dd=max_dd * 100,
|
||||||
|
exposure=bars_in / n * 100,
|
||||||
|
yearly=yearly,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC):
|
||||||
|
split = int(len(df) * (1 - frac))
|
||||||
|
ins = [e for e in entries if e["i"] < split]
|
||||||
|
oos = [e for e in entries if e["i"] >= split]
|
||||||
|
return ins, oos
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# criterio di accettazione
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
def verdict(full: SimResult, oos: SimResult) -> bool:
|
||||||
|
"""Strategia attendibile su un singolo asset/tf."""
|
||||||
|
if full.trades < 30:
|
||||||
|
return False
|
||||||
|
if full.ret <= 0 or oos.ret <= 0:
|
||||||
|
return False
|
||||||
|
if full.pos_years < max(full.n_years - 1, 1):
|
||||||
|
return False
|
||||||
|
if full.dd > 45:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Strategia #3 candidata: ROTAZIONE cross-sectional momentum (multi-crypto).
|
||||||
|
Una sola strategia che usa l'INTERO paniere: ad ogni ribilanciamento alloca il
|
||||||
|
capitale agli asset con momentum migliore (long-only). Cattura la dispersione tra
|
||||||
|
crypto (gli alt forti corrono molto piu' di BTC nei bull) senza shortare nulla.
|
||||||
|
|
||||||
|
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del bar
|
||||||
|
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import get_df, available_assets, FEE_RT # noqa: E402
|
||||||
|
|
||||||
|
LEV = 3.0
|
||||||
|
GROSS = 0.45 # esposizione lorda = LEV*POS del singolo (0.15*3) per confronto equo
|
||||||
|
|
||||||
|
|
||||||
|
def build_panel(assets: list[str], tf: str) -> pd.DataFrame:
|
||||||
|
"""Matrice close allineata per timestamp (inner join)."""
|
||||||
|
closes = {}
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, tf)
|
||||||
|
s = pd.Series(df["close"].values,
|
||||||
|
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
|
||||||
|
closes[a] = s[~s.index.duplicated()]
|
||||||
|
panel = pd.DataFrame(closes).dropna()
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_rotation(panel: pd.DataFrame, lookback=30, top_k=2,
|
||||||
|
fee_rt=FEE_RT, gross=GROSS, abs_filter=True,
|
||||||
|
oos_frac=0.0) -> dict:
|
||||||
|
"""Ad ogni barra: ranking per rendimento passato `lookback`; pesi uguali sui
|
||||||
|
top_k con momentum>0 (se abs_filter); altrimenti cash. gross = esposizione tot.
|
||||||
|
oos_frac>0: parte a investire solo dall'ultimo frac del campione."""
|
||||||
|
P = panel.values
|
||||||
|
T, N = P.shape
|
||||||
|
rets = np.zeros_like(P)
|
||||||
|
rets[1:] = P[1:] / P[:-1] - 1
|
||||||
|
years = panel.index.year.values
|
||||||
|
start = max(lookback + 1, int(T * (1 - oos_frac)) if oos_frac else lookback + 1)
|
||||||
|
cap = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
w = np.zeros(N)
|
||||||
|
yearly: dict[int, float] = {}
|
||||||
|
turn_total = 0.0
|
||||||
|
for i in range(start, T - 1):
|
||||||
|
mom = P[i] / P[i - lookback] - 1
|
||||||
|
order = np.argsort(mom)[::-1]
|
||||||
|
new_w = np.zeros(N)
|
||||||
|
chosen = [j for j in order if (mom[j] > 0 or not abs_filter)][:top_k]
|
||||||
|
if chosen:
|
||||||
|
for j in chosen:
|
||||||
|
new_w[j] = gross / len(chosen)
|
||||||
|
# fee sul turnover (one-way = fee_rt/2 su ogni variazione di peso)
|
||||||
|
turnover = np.abs(new_w - w).sum()
|
||||||
|
cap -= cap * turnover * (fee_rt / 2)
|
||||||
|
turn_total += turnover
|
||||||
|
w = new_w
|
||||||
|
port_ret = float(np.dot(w, rets[i + 1])) # rendimento bar i->i+1
|
||||||
|
cap = max(cap * (1 + port_ret), 10.0)
|
||||||
|
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||||
|
yearly[years[i]] = yearly.get(years[i], 0.0) + port_ret * 100
|
||||||
|
return {
|
||||||
|
"ret": (cap / 1000 - 1) * 100,
|
||||||
|
"dd": max_dd * 100,
|
||||||
|
"turnover": turn_total,
|
||||||
|
"yearly": yearly,
|
||||||
|
"pos_years": sum(1 for v in yearly.values() if v > 0),
|
||||||
|
"n_years": len(yearly),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print(f"ROTATION cross-sectional momentum — fee {FEE_RT*100:.2f}% RT, gross {GROSS} | OOS 30%")
|
||||||
|
print(f" Paniere: {assets}")
|
||||||
|
for tf in ["1d", "4h"]:
|
||||||
|
panel = build_panel(assets, tf)
|
||||||
|
print(f"\n === {tf} === panel {panel.shape[0]} barre, {panel.index[0].date()} -> {panel.index[-1].date()}")
|
||||||
|
print(f" {'config':<22s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Turn':>7s}{'AnniP':>8s}")
|
||||||
|
for lb in [20, 30, 60, 90]:
|
||||||
|
for k in [1, 2, 3]:
|
||||||
|
full = simulate_rotation(panel, lookback=lb, top_k=k)
|
||||||
|
oos = simulate_rotation(panel, lookback=lb, top_k=k, oos_frac=0.30)
|
||||||
|
anni = f"{full['pos_years']}/{full['n_years']}"
|
||||||
|
print(f" lb{lb:<3d} top{k:<14d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
|
||||||
|
f"{full['dd']:>6.0f}{full['turnover']:>7.0f}{anni:>8s}")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Strategia #3 candidata: time-series momentum / trend (TSMOM).
|
||||||
|
Posizione continua decisa a close[i] dai dati passati; fee SOLO sui cambi di
|
||||||
|
posizione (poche operazioni su TF alto = fee non letali). Niente look-ahead:
|
||||||
|
il rendimento del bar i->i+1 usa la direzione decisa a close[i].
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import ema, get_df, available_assets, FEE_RT # noqa: E402
|
||||||
|
|
||||||
|
LEV = 3.0
|
||||||
|
POS = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_position(sig: np.ndarray, df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||||
|
lev: float = LEV, pos: float = POS) -> dict:
|
||||||
|
"""sig[i] in {-1,0,1} = direzione tenuta nel bar i->i+1, decisa a close[i].
|
||||||
|
Fee one-way = fee_rt/2 su ogni unita' di variazione posizione."""
|
||||||
|
c = df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
cap = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
cur = 0.0
|
||||||
|
flips = 0
|
||||||
|
bars_in = 0
|
||||||
|
yearly: dict[int, float] = {}
|
||||||
|
for i in range(n - 1):
|
||||||
|
s = sig[i]
|
||||||
|
if not np.isfinite(s):
|
||||||
|
s = 0.0
|
||||||
|
if s != cur:
|
||||||
|
cap -= cap * pos * (fee_rt / 2) * lev * abs(s - cur)
|
||||||
|
flips += abs(s - cur) > 0
|
||||||
|
cur = s
|
||||||
|
pr = (c[i + 1] - c[i]) / c[i]
|
||||||
|
bar_ret = pos * lev * pr * cur
|
||||||
|
cap = max(cap * (1 + bar_ret), 10.0)
|
||||||
|
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||||
|
if cur != 0:
|
||||||
|
bars_in += 1
|
||||||
|
yr = ts.iloc[i].year
|
||||||
|
yearly[yr] = yearly.get(yr, 0.0) + bar_ret * 100
|
||||||
|
return {
|
||||||
|
"ret": (cap / 1000 - 1) * 100,
|
||||||
|
"dd": max_dd * 100,
|
||||||
|
"flips": flips,
|
||||||
|
"exposure": bars_in / n * 100,
|
||||||
|
"yearly": yearly,
|
||||||
|
"pos_years": sum(1 for v in yearly.values() if v > 0),
|
||||||
|
"n_years": len(yearly),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tsmom_signal(df, lookback=30, long_only=False):
|
||||||
|
"""+1 se close>close[-lookback], -1 (o 0 se long_only) altrimenti."""
|
||||||
|
c = df["close"].values
|
||||||
|
sig = np.zeros(len(c))
|
||||||
|
for i in range(lookback, len(c)):
|
||||||
|
up = c[i] > c[i - lookback]
|
||||||
|
sig[i] = 1.0 if up else (0.0 if long_only else -1.0)
|
||||||
|
return sig
|
||||||
|
|
||||||
|
|
||||||
|
def ema_dual_signal(df, fast=20, slow=100, long_only=False):
|
||||||
|
"""+1 se EMA_fast>EMA_slow."""
|
||||||
|
c = df["close"].values
|
||||||
|
ef, es = ema(c, fast), ema(c, slow)
|
||||||
|
sig = np.where(ef > es, 1.0, 0.0 if long_only else -1.0)
|
||||||
|
sig[:slow] = 0.0
|
||||||
|
return sig
|
||||||
|
|
||||||
|
|
||||||
|
def oos(sig, df, frac=0.30):
|
||||||
|
split = int(len(df) * (1 - frac))
|
||||||
|
s2 = sig.copy(); s2[:split] = 0.0
|
||||||
|
return simulate_position(s2, df)
|
||||||
|
|
||||||
|
|
||||||
|
def show(label, df, sig):
|
||||||
|
full = simulate_position(sig, df)
|
||||||
|
o = oos(sig, df)
|
||||||
|
anni = f"{full['pos_years']}/{full['n_years']}"
|
||||||
|
print(f" {label:<26s}{full['flips']:>6d}{full['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||||
|
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{anni:>8s}")
|
||||||
|
return full, o
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = available_assets()
|
||||||
|
print(f"TSMOM / trend — fee {FEE_RT*100:.2f}% RT, leva3x pos15% | OOS30%")
|
||||||
|
for tf in ["1d", "4h"]:
|
||||||
|
print(f"\n ###### TF {tf} ######")
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, tf)
|
||||||
|
print(f"\n === {a} {tf} === {'Flip':>5s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
|
||||||
|
show("TSMOM lb30 long/short", df, tsmom_signal(df, 30))
|
||||||
|
show("TSMOM lb30 long-only", df, tsmom_signal(df, 30, long_only=True))
|
||||||
|
show("TSMOM lb90 long/short", df, tsmom_signal(df, 90))
|
||||||
|
show("EMA 20/100 long/short", df, ema_dual_signal(df, 20, 100))
|
||||||
|
show("EMA 20/100 long-only", df, ema_dual_signal(df, 20, 100, long_only=True))
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""DIP01 — Dip-Buy Z-Score Reversion (long-only).
|
||||||
|
|
||||||
|
Variante robusta e ONESTA della famiglia mean-reversion: compra SOLO i dip
|
||||||
|
(close a z<=-z_in deviazioni sotto la media mobile) e prende profitto al rientro
|
||||||
|
verso la media. Niente short: nel campione 2018-2026 shortare cripto perde OOS
|
||||||
|
sistematicamente (vedi scripts/analysis/honest_final.py).
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
1. z-score = (close - SMA(n)) / STD(n)
|
||||||
|
2. ENTRY long quando z attraversa al ribasso -z_in (capitolazione)
|
||||||
|
3. EXIT: take-profit alla media mobile, stop-loss a sl_atr*ATR sotto l'entry,
|
||||||
|
o time-limit max_bars
|
||||||
|
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT Deribit, leva 3x, OOS = ultimo 30%):
|
||||||
|
BTC 1h: FULL +298% / OOS +59% / DD 23% / 7-9 anni positivi
|
||||||
|
ETH 1h: FULL +190% / OOS +224% / DD 54%
|
||||||
|
SOL 1h: FULL +50% / OOS +13% / DD 25%
|
||||||
|
Regge lo sweep fee fino a 0.20% RT (BTC OOS +45% anche a 0.20%).
|
||||||
|
Robusto su BTC/ETH/SOL (asset major); sugli alt molto parabolici (DOGE/BNB)
|
||||||
|
non ha edge -> usare solo su BTC/ETH/SOL.
|
||||||
|
|
||||||
|
Compatibile con StrategyWorker: ogni Signal porta tp/sl/max_bars in metadata.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class DipReversion(Strategy):
|
||||||
|
name = "DIP01_dip_reversion"
|
||||||
|
description = "Long-only dip-buy z-score reversion, TP alla media"
|
||||||
|
default_assets = ["BTC", "ETH", "SOL"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
fee_rt = 0.001
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
initial_capital = 1000.0
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
**params) -> list[Signal]:
|
||||||
|
c = df["close"].values
|
||||||
|
n = params.get("n", 50)
|
||||||
|
z_in = params.get("z_in", 2.5)
|
||||||
|
sl_atr = params.get("sl_atr", 2.5)
|
||||||
|
max_bars = params.get("max_bars", 24)
|
||||||
|
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = _atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
|
||||||
|
signals: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
signals.append(Signal(
|
||||||
|
idx=i, direction=1, entry_price=c[i],
|
||||||
|
metadata={"tp": float(ma[i]), "sl": float(c[i] - sl_atr * a[i]),
|
||||||
|
"max_bars": max_bars},
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||||
|
**params) -> BacktestResult | None:
|
||||||
|
df = load_data(asset, tf)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
signals = self.generate_signals(df, ts, **params)
|
||||||
|
if not signals:
|
||||||
|
return None
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
fee = self.fee_rt * self.leverage
|
||||||
|
capital = peak = float(self.initial_capital)
|
||||||
|
max_dd = 0.0
|
||||||
|
total_bars = 0
|
||||||
|
last_exit = -1
|
||||||
|
yearly: dict[int, dict] = {}
|
||||||
|
|
||||||
|
for sig in signals:
|
||||||
|
i, d = sig.idx, sig.direction
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]
|
||||||
|
j = min(i + mb, n - 1)
|
||||||
|
for step in range(1, mb + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1; exit_p = c[j]; break
|
||||||
|
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
|
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hit_sl:
|
||||||
|
exit_p = sl; break
|
||||||
|
if hit_tp:
|
||||||
|
exit_p = tp; break
|
||||||
|
if step == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||||
|
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||||
|
if capital > peak:
|
||||||
|
peak = capital
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
total_bars += (j - i)
|
||||||
|
last_exit = j
|
||||||
|
year = ts.iloc[i].year
|
||||||
|
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||||
|
yr["t"] += 1
|
||||||
|
if ret > 0:
|
||||||
|
yr["w"] += 1
|
||||||
|
yr["pnl"] += ret * self.initial_capital
|
||||||
|
|
||||||
|
all_t = sum(v["t"] for v in yearly.values())
|
||||||
|
all_w = sum(v["w"] for v in yearly.values())
|
||||||
|
if all_t == 0:
|
||||||
|
return None
|
||||||
|
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||||
|
return BacktestResult(
|
||||||
|
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||||
|
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||||
|
capital=capital, initial_capital=self.initial_capital,
|
||||||
|
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||||
|
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||||
|
years_active=len(yearly), yearly=yearly_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
strat = DipReversion()
|
||||||
|
print(f"{'=' * 100}")
|
||||||
|
print(f" DIP01 DIP-BUY REVERSION — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||||
|
print(f"{'=' * 100}")
|
||||||
|
for asset in ["BTC", "ETH", "SOL"]:
|
||||||
|
r = strat.backtest(asset, "1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
|
||||||
|
if r:
|
||||||
|
r.strategy_name = f"DIP01 {asset} 1h"
|
||||||
|
r.print_summary()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""ROT01 — Cross-Sectional Momentum Rotation (multi-crypto, long-only), 1d.
|
||||||
|
|
||||||
|
UNA strategia che usa l'INTERO paniere di crypto in un solo book: ogni giorno
|
||||||
|
ordina gli asset per momentum (rendimento sugli ultimi `lookback` giorni) e alloca
|
||||||
|
il capitale in parti uguali ai `top_k` con momentum positivo; il resto in cash.
|
||||||
|
Cattura la dispersione tra crypto (gli alt forti corrono molto piu' di BTC nei bull)
|
||||||
|
senza shortare nulla. Meccanismo distinto da DIP01/TR01 -> vera diversificazione.
|
||||||
|
|
||||||
|
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del giorno
|
||||||
|
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%):
|
||||||
|
lb=60 top2 -> FULL +679% / OOS +44% / DD 53% / 5-7 anni positivi.
|
||||||
|
Param-insensitive (tutte le lb/k positive) e regge fee fino 0.20% RT (OOS +41%).
|
||||||
|
Per-anno: 2020+33 2021+181 2022-29 2023+43 2024+59 2025+6 2026-10 (i negativi = bear).
|
||||||
|
Dettagli in scripts/analysis/honest_rotation.py / honest_final.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_rotation import build_panel, simulate_rotation # noqa: E402
|
||||||
|
from scripts.analysis.honest_lab import available_assets
|
||||||
|
|
||||||
|
LOOKBACK, TOP_K, TF = 60, 2, "1d"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
assets = available_assets()
|
||||||
|
panel = build_panel(assets, TF)
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" ROT01 ROTAZIONE cross-sectional momentum | {TF} lb={LOOKBACK} top{TOP_K} | netto fee 0.10% RT")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" Paniere: {list(panel.columns)}")
|
||||||
|
print(f" Periodo: {panel.index[0].date()} -> {panel.index[-1].date()} ({panel.shape[0]} barre)")
|
||||||
|
full = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001)
|
||||||
|
oos = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001, oos_frac=0.30)
|
||||||
|
print(f"\n FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% turnover {full['turnover']:.0f}")
|
||||||
|
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
|
||||||
|
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""TR01 — EMA Trend Following (long-only), timeframe 4h.
|
||||||
|
|
||||||
|
Cavalca i trend rialzisti, si mette in cash nei downtrend. Niente short
|
||||||
|
(shortare cripto perde OOS nel campione 2018-2026). Complementare a DIP01:
|
||||||
|
DIP01 guadagna nei regimi di reversione, TR01 nei regimi di trend.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
1. EMA fast (20) e EMA slow (100) sul close
|
||||||
|
2. LONG quando EMA_fast > EMA_slow (uptrend), altrimenti CASH
|
||||||
|
3. posizione continua, decisione a close[i] (no look-ahead);
|
||||||
|
fee solo sui cambi di stato (poche operazioni = fee non letali)
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT, leva 3x, pos 15%, OOS = ultimo 30%):
|
||||||
|
robusto FULL+OOS su 5/8 asset: BNB(+14), BTC(+27), DOGE(+53), SOL(+7), XRP(+29) OOS.
|
||||||
|
ETH ~flat, ADA/LTC negativi OOS -> preferire BNB/BTC/DOGE/SOL/XRP.
|
||||||
|
Dettagli in scripts/analysis/honest_final.py / honest_trend.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_trend import ( # noqa: E402
|
||||||
|
simulate_position, ema_dual_signal, oos as trend_oos,
|
||||||
|
)
|
||||||
|
from scripts.analysis.honest_lab import get_df
|
||||||
|
|
||||||
|
ASSETS = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
|
||||||
|
FAST, SLOW, TF = 20, 100, "4h"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" TR01 EMA TREND {FAST}/{SLOW} long-only | {TF} | netto fee 0.10% RT leva 3x pos 15%")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}")
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_df(a, TF)
|
||||||
|
sig = ema_dual_signal(df, FAST, SLOW, long_only=True)
|
||||||
|
f = simulate_position(sig, df)
|
||||||
|
o = trend_oos(sig, df)
|
||||||
|
print(f" {a:<6s}{f['flips']:>6d}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||||
|
f"{f['dd']:>6.0f}{f['exposure']:>6.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>9s}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
Reference in New Issue
Block a user