refactor: riorganizzazione script — Strategy ABC, folder strategies/waste/analysis

- src/strategies/base.py: Strategy ABC con Signal, BacktestResult, YearlyStats
- src/strategies/indicators.py: keltner_ratio, detect_squeezes, ema, atr, rv, corr
- scripts/strategies/: SQ01-SQ04 (squeeze puro/filtri), ML01 (squeeze+GBM)
- scripts/waste/: W01-W22 script scartati + REF originali
- scripts/analysis/: compare, best_yearly, final_report, paper_status
- CLAUDE.md aggiornato con nuova struttura e tabella strategie

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:01:36 +02:00
parent fa2d74be77
commit 0e47956f7a
40 changed files with 1747 additions and 12 deletions
+11
View File
@@ -0,0 +1,11 @@
"""Strategie di trading — classe base e indicatori condivisi."""
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, atr, rv_annualized, rolling_correlation,
)
__all__ = [
"Strategy", "Signal", "BacktestResult", "YearlyStats",
"keltner_ratio", "detect_squeezes", "ema", "atr",
"rv_annualized", "rolling_correlation",
]
+243
View File
@@ -0,0 +1,243 @@
"""Classe base astratta per tutte le strategie di trading."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from src.data.downloader import load_data
@dataclass
class Signal:
"""Segnale di trading generato da una strategia."""
idx: int
direction: int # +1 long, -1 short
entry_price: float
metadata: dict = field(default_factory=dict)
@dataclass
class YearlyStats:
year: int
trades: int
wins: int
pnl: float
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@dataclass
class BacktestResult:
"""Risultato completo di un backtest."""
strategy_name: str
asset: str
timeframe: str
params: dict
trades: int
wins: int
pnl: float
capital: float
initial_capital: float
max_dd: float
time_in_market_pct: float
avg_trade_duration_h: float
years_active: int
yearly: list[YearlyStats]
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@property
def sharpe(self) -> float:
pnls = []
for ys in self.yearly:
pnls.append(ys.pnl)
if len(pnls) < 2 or np.std(pnls) == 0:
return 0.0
return float(np.mean(pnls) / np.std(pnls) * np.sqrt(len(pnls)))
@property
def daily_pnl(self) -> float:
return self.pnl / (self.years_active * 365) if self.years_active > 0 else 0.0
@property
def worst_year(self) -> YearlyStats | None:
valid = [y for y in self.yearly if y.trades >= 10]
if not valid:
valid = self.yearly
return min(valid, key=lambda y: y.accuracy) if valid else None
def print_summary(self):
worst = self.worst_year
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
dur = f"{self.avg_trade_duration_h:.0f}h" if self.avg_trade_duration_h >= 1 else f"{self.avg_trade_duration_h * 60:.0f}m"
print(f" {self.strategy_name:<30s} {self.asset:>3s} {self.timeframe:>3s} "
f"{self.trades:>5d}t {self.accuracy:>5.1f}% "
f"{self.pnl:>+9.0f} DD {self.max_dd:>4.1f}% "
f"€/d {self.daily_pnl:>+6.2f} "
f"Mkt {self.time_in_market_pct:>4.1f}% {dur:>5s} "
f"worst={worst_str} {self.years_active}y")
def print_yearly(self):
print(f"\n {self.strategy_name} [{self.asset} {self.timeframe}] — per anno:")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for ys in sorted(self.yearly, key=lambda y: y.year):
print(f" {ys.year:>6d} {ys.trades:>7d} {ys.accuracy:>5.1f}% €{ys.pnl:>+8.0f}")
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
class Strategy(ABC):
"""Classe base per tutte le strategie.
Sottoclassi devono implementare:
- name, description, default_assets, default_timeframes
- generate_signals(df, timestamps, **params) -> list[Signal]
"""
name: str = "unnamed"
description: str = ""
default_assets: list[str] = ["BTC", "ETH"]
default_timeframes: list[str] = ["15m", "1h"]
# Parametri di backtest
fee_rt: float = 0.002
leverage: float = 3.0
position_size: float = 0.15
initial_capital: float = 1000.0
@abstractmethod
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali di trading dal dataframe OHLCV.
Args:
df: DataFrame con colonne open, high, low, close, volume, timestamp
ts: DatetimeIndex UTC dei timestamp
**params: parametri specifici della strategia
Returns:
Lista di Signal con idx, direction, entry_price
"""
...
def backtest(self, asset: str, tf: str, hold: int = 3,
**params) -> BacktestResult | None:
"""Esegue backtest su un asset/timeframe."""
df = load_data(asset, tf)
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sig_params = {**params, "asset": asset, "tf": tf}
signals = self.generate_signals(df, ts, **sig_params)
if not signals:
return None
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * net
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0:
return None
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params=params,
trades=all_t,
wins=all_w,
pnl=sum(d["pnl"] for d 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=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
def run_all(self, assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params) -> list[BacktestResult]:
"""Esegue backtest su tutte le combinazioni asset/timeframe."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
results = []
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 20:
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
return results
def report(self, results: list[BacktestResult] | None = None,
assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params):
"""Esegue e stampa report completo."""
if results is None:
results = self.run_all(assets, timeframes, hold, **params)
print(f"\n{'=' * 120}")
print(f" {self.name}{self.description}")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in results:
r.print_summary()
if results:
best = results[0]
best.print_yearly()
return results
+102
View File
@@ -0,0 +1,102 @@
"""Indicatori tecnici condivisi tra tutte le strategie."""
from __future__ import annotations
import numpy as np
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray,
window: int = 14) -> np.ndarray:
"""Rapporto Bollinger / Keltner. Sotto 1 = squeeze (BB dentro KC)."""
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window:i]
wh = high[i - window:i]
wl = low[i - window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(
wh - wl,
np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))),
)
atr = np.mean(tr[1:])
kc = (ma + 1.5 * atr) - (ma - 1.5 * atr)
bb = (ma + 2 * bb_std) - (ma - 2 * bb_std)
if kc > 0:
r[i] = bb / kc
return r
def detect_squeezes(close: np.ndarray, high: np.ndarray, low: np.ndarray,
kcr: np.ndarray, sq_thr: float = 0.8,
min_dur: int = 5) -> list[dict]:
"""Rileva squeeze events: periodi dove BB sta dentro KC."""
events: list[dict] = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
events.append({
"idx": i, "dur": dur, "sq_start": sq_start,
"kcr_at_release": kcr[i],
})
return events
def ema(arr: np.ndarray, period: int) -> np.ndarray:
"""Exponential Moving Average."""
r = np.full(len(arr), np.nan)
k = 2 / (period + 1)
r[period - 1] = np.mean(arr[:period])
for i in range(period, len(arr)):
r[i] = arr[i] * k + r[i - 1] * (1 - k)
return r
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray,
period: int = 14) -> np.ndarray:
"""Average True Range (EMA-smoothed)."""
tr = np.maximum(
high - low,
np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))),
)
tr[0] = high[0] - low[0]
r = np.full(len(close), np.nan)
r[period - 1] = np.mean(tr[:period])
k = 2 / (period + 1)
for i in range(period, len(close)):
r[i] = tr[i] * k + r[i - 1] * (1 - k)
return r
def rv_annualized(close: np.ndarray, window: int) -> np.ndarray:
"""Realized volatility annualizzata (hourly data assumed)."""
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i + 1] = np.std(lr[i - window:i]) * np.sqrt(24 * 365)
return r
def rolling_correlation(close_a: np.ndarray, close_b: np.ndarray,
window: int = 48) -> np.ndarray:
"""Correlazione rolling tra rendimenti logaritmici di due asset."""
n = max(len(close_a), len(close_b))
ret_a = np.diff(np.log(np.where(close_a == 0, 1e-10, close_a)))
ret_b = np.diff(np.log(np.where(close_b[:len(close_a)] == 0, 1e-10, close_b[:len(close_a)])))
min_len = min(len(ret_a), len(ret_b))
corr = np.full(n, np.nan)
for i in range(window, min_len):
cv = np.corrcoef(ret_a[i - window:i], ret_b[i - window:i])[0, 1]
corr[i + 1] = cv if np.isfinite(cv) else 0
return corr