feat: strategie 1-10, framework analisi frattale, download dati storici BTC/ETH
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Backtesting engine with fee support and performance metrics."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class Side(Enum):
|
||||
LONG = 1
|
||||
SHORT = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
entry_idx: int
|
||||
exit_idx: int
|
||||
side: Side
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
size: float
|
||||
fee_pct: float
|
||||
|
||||
@property
|
||||
def gross_pnl(self) -> float:
|
||||
if self.side == Side.LONG:
|
||||
return (self.exit_price - self.entry_price) * self.size
|
||||
return (self.entry_price - self.exit_price) * self.size
|
||||
|
||||
@property
|
||||
def fee(self) -> float:
|
||||
return self.fee_pct * (self.entry_price + self.exit_price) * self.size
|
||||
|
||||
@property
|
||||
def net_pnl(self) -> float:
|
||||
return self.gross_pnl - self.fee
|
||||
|
||||
@property
|
||||
def net_return(self) -> float:
|
||||
cost = self.entry_price * self.size + self.fee_pct * self.entry_price * self.size
|
||||
if cost == 0:
|
||||
return 0.0
|
||||
return self.net_pnl / cost
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
trades: list[Trade]
|
||||
initial_capital: float
|
||||
final_capital: float
|
||||
equity_curve: list[float]
|
||||
|
||||
@property
|
||||
def total_trades(self) -> int:
|
||||
return len(self.trades)
|
||||
|
||||
@property
|
||||
def win_rate(self) -> float:
|
||||
if not self.trades:
|
||||
return 0.0
|
||||
wins = sum(1 for t in self.trades if t.net_pnl > 0)
|
||||
return wins / len(self.trades)
|
||||
|
||||
@property
|
||||
def total_return(self) -> float:
|
||||
if self.initial_capital == 0:
|
||||
return 0.0
|
||||
return (self.final_capital - self.initial_capital) / self.initial_capital
|
||||
|
||||
@property
|
||||
def annualized_return(self) -> float:
|
||||
if not self.trades or self.total_return <= -1:
|
||||
return -1.0
|
||||
days = (self.trades[-1].exit_idx - self.trades[0].entry_idx) / 24
|
||||
if days <= 0:
|
||||
return 0.0
|
||||
years = days / 365.25
|
||||
if years == 0:
|
||||
return 0.0
|
||||
return (1 + self.total_return) ** (1 / years) - 1
|
||||
|
||||
@property
|
||||
def max_drawdown(self) -> float:
|
||||
if not self.equity_curve:
|
||||
return 0.0
|
||||
peak = self.equity_curve[0]
|
||||
max_dd = 0.0
|
||||
for val in self.equity_curve:
|
||||
if val > peak:
|
||||
peak = val
|
||||
dd = (peak - val) / peak if peak > 0 else 0
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
return max_dd
|
||||
|
||||
@property
|
||||
def sharpe_ratio(self) -> float:
|
||||
if len(self.equity_curve) < 2:
|
||||
return 0.0
|
||||
eq = np.array(self.equity_curve)
|
||||
returns = np.diff(eq) / eq[:-1]
|
||||
returns = returns[np.isfinite(returns)]
|
||||
if len(returns) == 0 or np.std(returns) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(returns) / np.std(returns) * np.sqrt(252 * 24))
|
||||
|
||||
@property
|
||||
def profit_factor(self) -> float:
|
||||
gross_wins = sum(t.net_pnl for t in self.trades if t.net_pnl > 0)
|
||||
gross_losses = abs(sum(t.net_pnl for t in self.trades if t.net_pnl < 0))
|
||||
if gross_losses == 0:
|
||||
return float("inf") if gross_wins > 0 else 0.0
|
||||
return gross_wins / gross_losses
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"total_trades": self.total_trades,
|
||||
"win_rate": round(self.win_rate * 100, 1),
|
||||
"total_return_pct": round(self.total_return * 100, 1),
|
||||
"annualized_return_pct": round(self.annualized_return * 100, 1),
|
||||
"max_drawdown_pct": round(self.max_drawdown * 100, 1),
|
||||
"sharpe_ratio": round(self.sharpe_ratio, 2),
|
||||
"profit_factor": round(self.profit_factor, 2),
|
||||
"initial_capital": self.initial_capital,
|
||||
"final_capital": round(self.final_capital, 2),
|
||||
}
|
||||
|
||||
|
||||
def run_backtest(
|
||||
df: pd.DataFrame,
|
||||
signals: pd.Series,
|
||||
initial_capital: float = 1000.0,
|
||||
fee_pct: float = 0.001,
|
||||
position_size_pct: float = 1.0,
|
||||
max_hold_candles: int = 24,
|
||||
) -> BacktestResult:
|
||||
"""Run backtest on signals.
|
||||
|
||||
signals: Series with same index as df.
|
||||
+1 = go long, -1 = go short, 0 = no signal
|
||||
"""
|
||||
capital = initial_capital
|
||||
trades: list[Trade] = []
|
||||
equity_curve: list[float] = [capital]
|
||||
in_position = False
|
||||
entry_idx = 0
|
||||
entry_price = 0.0
|
||||
current_side = Side.LONG
|
||||
size = 0.0
|
||||
|
||||
for i in range(len(df)):
|
||||
sig = signals.iloc[i] if i < len(signals) else 0
|
||||
|
||||
if in_position:
|
||||
hold_time = i - entry_idx
|
||||
exit_price = df["close"].iloc[i]
|
||||
should_exit = (
|
||||
hold_time >= max_hold_candles
|
||||
or (current_side == Side.LONG and sig == -1)
|
||||
or (current_side == Side.SHORT and sig == 1)
|
||||
)
|
||||
|
||||
if should_exit:
|
||||
trade = Trade(
|
||||
entry_idx=entry_idx,
|
||||
exit_idx=i,
|
||||
side=current_side,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
size=size,
|
||||
fee_pct=fee_pct,
|
||||
)
|
||||
capital += trade.net_pnl
|
||||
trades.append(trade)
|
||||
in_position = False
|
||||
|
||||
if not in_position and sig != 0 and capital > 0:
|
||||
entry_idx = i
|
||||
entry_price = df["close"].iloc[i]
|
||||
current_side = Side.LONG if sig > 0 else Side.SHORT
|
||||
alloc = capital * position_size_pct
|
||||
size = alloc / entry_price
|
||||
in_position = True
|
||||
|
||||
equity_curve.append(capital)
|
||||
|
||||
if in_position:
|
||||
exit_price = df["close"].iloc[-1]
|
||||
trade = Trade(
|
||||
entry_idx=entry_idx,
|
||||
exit_idx=len(df) - 1,
|
||||
side=current_side,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
size=size,
|
||||
fee_pct=fee_pct,
|
||||
)
|
||||
capital += trade.net_pnl
|
||||
trades.append(trade)
|
||||
equity_curve.append(capital)
|
||||
|
||||
return BacktestResult(
|
||||
trades=trades,
|
||||
initial_capital=initial_capital,
|
||||
final_capital=capital,
|
||||
equity_curve=equity_curve,
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Download historical OHLCV data. Primary: Cerbero MCP. Fallback: Binance/ccxt."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "raw"
|
||||
|
||||
CERBERO_URL = "https://cerbero-mcp.tielogic.xyz"
|
||||
CERBERO_TOKEN = "_hm0FkyC67P9OXJTy7R9SE2lfhGz_Wa6i89KqH_uXrk"
|
||||
CERBERO_HEADERS = {
|
||||
"Authorization": f"Bearer {CERBERO_TOKEN}",
|
||||
"X-Bot-Tag": "pythagoras-downloader",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
ASSETS = {
|
||||
"BTC": {
|
||||
"deribit": {"instrument": "BTC-PERPETUAL", "start": "2018-09-01"},
|
||||
"binance_symbol": "BTC/USDT",
|
||||
"binance_start": "2018-01-01",
|
||||
},
|
||||
"ETH": {
|
||||
"deribit": {"instrument": "ETH-PERPETUAL", "start": "2019-06-01"},
|
||||
"binance_symbol": "ETH/USDT",
|
||||
"binance_start": "2018-01-01",
|
||||
},
|
||||
}
|
||||
|
||||
TIMEFRAMES = ["1m", "5m", "15m", "1h"]
|
||||
|
||||
DERIBIT_RESOLUTION = {"1m": "1", "5m": "5", "15m": "15", "1h": "60"}
|
||||
|
||||
TF_SECONDS = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}
|
||||
|
||||
MAX_DAYS_PER_REQUEST = {"1m": 1, "5m": 5, "15m": 15, "1h": 30}
|
||||
|
||||
|
||||
def _parquet_path(asset: str, tf: str) -> Path:
|
||||
return DATA_DIR / f"{asset.lower()}_{tf}.parquet"
|
||||
|
||||
|
||||
def _fetch_deribit(instrument: str, resolution: str, start: str, end: str) -> list[dict]:
|
||||
resp = requests.post(
|
||||
f"{CERBERO_URL}/mcp-deribit/tools/get_historical",
|
||||
headers=CERBERO_HEADERS,
|
||||
json={
|
||||
"instrument": instrument,
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"resolution": resolution,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("candles", [])
|
||||
|
||||
|
||||
def _fetch_binance(symbol: str, tf: str, since_ms: int, limit: int = 1000) -> list[list]:
|
||||
import ccxt
|
||||
exchange = ccxt.binance({"enableRateLimit": True, "options": {"defaultType": "spot"}})
|
||||
return exchange.fetch_ohlcv(symbol, tf, since=since_ms, limit=limit)
|
||||
|
||||
|
||||
def _download_cerbero_range(
|
||||
instrument: str, resolution: str, tf: str, start_date: str, end_date: str
|
||||
) -> pd.DataFrame:
|
||||
all_candles: list[dict] = []
|
||||
max_days = MAX_DAYS_PER_REQUEST[tf]
|
||||
current = datetime.fromisoformat(start_date)
|
||||
end = datetime.fromisoformat(end_date)
|
||||
|
||||
pbar = tqdm(
|
||||
total=(end - current).days,
|
||||
desc=f" Cerbero {instrument} {tf}",
|
||||
unit="days",
|
||||
)
|
||||
|
||||
while current < end:
|
||||
chunk_end = min(current + timedelta(days=max_days), end)
|
||||
start_str = current.strftime("%Y-%m-%d")
|
||||
end_str = chunk_end.strftime("%Y-%m-%d")
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
candles = _fetch_deribit(instrument, resolution, start_str, end_str)
|
||||
all_candles.extend(candles)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
print(f" SKIP {start_str}→{end_str}: {e}")
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
pbar.update(max_days)
|
||||
current = chunk_end
|
||||
|
||||
pbar.close()
|
||||
|
||||
if not all_candles:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(all_candles)
|
||||
df = df.rename(columns={"timestamp": "timestamp"})
|
||||
df["timestamp"] = df["timestamp"].astype("int64")
|
||||
return df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
|
||||
def _download_binance_range(
|
||||
symbol: str, tf: str, start_date: str, end_date: str
|
||||
) -> pd.DataFrame:
|
||||
import ccxt
|
||||
|
||||
exchange = ccxt.binance({
|
||||
"enableRateLimit": True,
|
||||
"timeout": 30000,
|
||||
"options": {"defaultType": "spot", "adjustForTimeDifference": True},
|
||||
})
|
||||
exchange.load_markets()
|
||||
|
||||
start_ms = int(datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||
end_ms = int(datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||
tf_ms = TF_SECONDS[tf] * 1000
|
||||
all_rows: list[list] = []
|
||||
|
||||
pbar = tqdm(desc=f" Binance {symbol} {tf}", unit=" candles")
|
||||
|
||||
since = start_ms
|
||||
while since < end_ms:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, tf, since=since, limit=1000)
|
||||
break
|
||||
except ccxt.RateLimitExceeded:
|
||||
time.sleep(10)
|
||||
ohlcv = []
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
print(f" ERR: {e}")
|
||||
time.sleep(2 ** attempt)
|
||||
ohlcv = []
|
||||
|
||||
if not ohlcv:
|
||||
break
|
||||
|
||||
filtered = [c for c in ohlcv if c[0] < end_ms]
|
||||
all_rows.extend(filtered)
|
||||
pbar.update(len(filtered))
|
||||
since = ohlcv[-1][0] + tf_ms
|
||||
|
||||
if len(ohlcv) < 1000 or ohlcv[-1][0] >= end_ms:
|
||||
break
|
||||
|
||||
pbar.close()
|
||||
|
||||
if not all_rows:
|
||||
return pd.DataFrame()
|
||||
|
||||
cols = ["timestamp", "open", "high", "low", "close", "volume"]
|
||||
df = pd.DataFrame(all_rows, columns=cols)
|
||||
df["timestamp"] = df["timestamp"].astype("int64")
|
||||
return df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
|
||||
def download_asset(asset: str, tf: str) -> pd.DataFrame:
|
||||
path = _parquet_path(asset, tf)
|
||||
info = ASSETS[asset]
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
resolution = DERIBIT_RESOLUTION[tf]
|
||||
|
||||
parts: list[pd.DataFrame] = []
|
||||
|
||||
binance_start = info["binance_start"]
|
||||
deribit_start = info["deribit"]["start"]
|
||||
|
||||
if binance_start < deribit_start:
|
||||
print(f"\n Fase 1: Binance {binance_start} → {deribit_start}")
|
||||
df_binance = _download_binance_range(
|
||||
info["binance_symbol"], tf, binance_start, deribit_start
|
||||
)
|
||||
if not df_binance.empty:
|
||||
parts.append(df_binance)
|
||||
|
||||
print(f"\n Fase 2: Cerbero/Deribit {deribit_start} → {today}")
|
||||
df_deribit = _download_cerbero_range(
|
||||
info["deribit"]["instrument"], resolution, tf, deribit_start, today
|
||||
)
|
||||
if not df_deribit.empty:
|
||||
parts.append(df_deribit)
|
||||
|
||||
if not parts:
|
||||
print(f" VUOTO: {asset} {tf}")
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.concat(parts).drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_parquet(path, index=False)
|
||||
|
||||
first = datetime.fromtimestamp(df["timestamp"].iloc[0] / 1000, tz=timezone.utc)
|
||||
last = datetime.fromtimestamp(df["timestamp"].iloc[-1] / 1000, tz=timezone.utc)
|
||||
print(f" ✓ {path.name}: {len(df)} candele [{first.date()} → {last.date()}]")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def download_all() -> dict[str, dict[str, pd.DataFrame]]:
|
||||
result: dict[str, dict[str, pd.DataFrame]] = {}
|
||||
for asset in ASSETS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {asset}")
|
||||
print(f"{'='*60}")
|
||||
result[asset] = {}
|
||||
for tf in TIMEFRAMES:
|
||||
result[asset][tf] = download_asset(asset, tf)
|
||||
return result
|
||||
|
||||
|
||||
def load_data(asset: str = "BTC", tf: str = "1h") -> pd.DataFrame:
|
||||
path = _parquet_path(asset, tf)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Dati non trovati: {path}. Esegui download_all().")
|
||||
df = pd.read_parquet(path)
|
||||
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df
|
||||
|
||||
|
||||
def data_summary() -> pd.DataFrame:
|
||||
rows = []
|
||||
for asset in ASSETS:
|
||||
for tf in TIMEFRAMES:
|
||||
path = _parquet_path(asset, tf)
|
||||
if path.exists():
|
||||
df = pd.read_parquet(path)
|
||||
first = datetime.fromtimestamp(df["timestamp"].iloc[0] / 1000, tz=timezone.utc)
|
||||
last = datetime.fromtimestamp(df["timestamp"].iloc[-1] / 1000, tz=timezone.utc)
|
||||
rows.append({
|
||||
"asset": asset,
|
||||
"tf": tf,
|
||||
"candles": len(df),
|
||||
"from": first.date(),
|
||||
"to": last.date(),
|
||||
"size_mb": round(path.stat().st_size / 1024 / 1024, 1),
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
download_all()
|
||||
print("\n" + "=" * 60)
|
||||
print(data_summary().to_string(index=False))
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Fractal indicators: Hurst exponent, fractal dimension, self-similarity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.stats import linregress
|
||||
|
||||
|
||||
def hurst_exponent(series: np.ndarray, max_lag: int | None = None) -> float:
|
||||
"""Compute Hurst exponent via R/S analysis.
|
||||
H > 0.5: trending (persistent), H < 0.5: mean-reverting, H ≈ 0.5: random walk.
|
||||
"""
|
||||
n = len(series)
|
||||
if n < 20:
|
||||
return 0.5
|
||||
|
||||
if max_lag is None:
|
||||
max_lag = min(n // 4, 100)
|
||||
|
||||
lags = range(10, max_lag + 1)
|
||||
rs_values = []
|
||||
lag_values = []
|
||||
|
||||
for lag in lags:
|
||||
rs_list = []
|
||||
for start in range(0, n - lag, lag):
|
||||
chunk = series[start : start + lag]
|
||||
if len(chunk) < lag:
|
||||
continue
|
||||
mean = np.mean(chunk)
|
||||
deviations = np.cumsum(chunk - mean)
|
||||
r = np.max(deviations) - np.min(deviations)
|
||||
s = np.std(chunk, ddof=1)
|
||||
if s > 0:
|
||||
rs_list.append(r / s)
|
||||
|
||||
if rs_list:
|
||||
rs_values.append(np.mean(rs_list))
|
||||
lag_values.append(lag)
|
||||
|
||||
if len(lag_values) < 3:
|
||||
return 0.5
|
||||
|
||||
log_lags = np.log(lag_values)
|
||||
log_rs = np.log(rs_values)
|
||||
slope, _, _, _, _ = linregress(log_lags, log_rs)
|
||||
return float(np.clip(slope, 0, 1))
|
||||
|
||||
|
||||
def rolling_hurst(close: np.ndarray, window: int = 100, step: int = 1) -> np.ndarray:
|
||||
"""Compute rolling Hurst exponent."""
|
||||
n = len(close)
|
||||
result = np.full(n, 0.5)
|
||||
returns = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
|
||||
for i in range(window, n, step):
|
||||
h = hurst_exponent(returns[i - window : i])
|
||||
result[i] = h
|
||||
for j in range(1, min(step, n - i)):
|
||||
result[i + j] = h
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fractal_dimension_higuchi(series: np.ndarray, k_max: int = 10) -> float:
|
||||
"""Higuchi fractal dimension of a time series."""
|
||||
n = len(series)
|
||||
if n < k_max * 2:
|
||||
return 1.5
|
||||
|
||||
lk = []
|
||||
x = np.arange(1, k_max + 1)
|
||||
|
||||
for k in range(1, k_max + 1):
|
||||
lm_list = []
|
||||
for m in range(1, k + 1):
|
||||
indices = np.arange(m - 1, n, k)
|
||||
if len(indices) < 2:
|
||||
continue
|
||||
vals = series[indices]
|
||||
length = np.sum(np.abs(np.diff(vals)))
|
||||
norm = (n - 1) / (k * ((n - m) // k) * k)
|
||||
lm_list.append(length * norm)
|
||||
|
||||
if lm_list:
|
||||
lk.append(np.mean(lm_list))
|
||||
|
||||
if len(lk) < 3:
|
||||
return 1.5
|
||||
|
||||
log_k = np.log(1.0 / x[: len(lk)])
|
||||
log_lk = np.log(np.array(lk))
|
||||
slope, _, _, _, _ = linregress(log_k, log_lk)
|
||||
return float(np.clip(slope, 1.0, 2.0))
|
||||
|
||||
|
||||
def self_similarity_score(close: np.ndarray, window: int, scales: list[int] | None = None) -> float:
|
||||
"""Measure self-similarity across multiple time scales.
|
||||
Higher score = more fractal (self-similar) structure.
|
||||
"""
|
||||
if scales is None:
|
||||
scales = [2, 3, 4, 6]
|
||||
|
||||
if len(close) < window:
|
||||
return 0.0
|
||||
|
||||
base = close[-window:]
|
||||
base_returns = np.diff(np.log(np.where(base == 0, 1e-10, base)))
|
||||
if np.std(base_returns) == 0:
|
||||
return 0.0
|
||||
|
||||
similarities = []
|
||||
for scale in scales:
|
||||
scaled_window = window * scale
|
||||
if scaled_window > len(close):
|
||||
continue
|
||||
|
||||
scaled = close[-scaled_window:]
|
||||
step = scale
|
||||
downsampled = scaled[::step][:window]
|
||||
|
||||
if len(downsampled) != len(base):
|
||||
downsampled = np.interp(
|
||||
np.linspace(0, 1, window),
|
||||
np.linspace(0, 1, len(downsampled)),
|
||||
downsampled,
|
||||
)
|
||||
|
||||
ds_returns = np.diff(np.log(np.where(downsampled == 0, 1e-10, downsampled)))
|
||||
|
||||
if len(ds_returns) != len(base_returns):
|
||||
ds_returns = np.interp(
|
||||
np.linspace(0, 1, len(base_returns)),
|
||||
np.linspace(0, 1, len(ds_returns)),
|
||||
ds_returns,
|
||||
)
|
||||
|
||||
std_ds = np.std(ds_returns)
|
||||
if std_ds == 0:
|
||||
continue
|
||||
|
||||
corr = np.corrcoef(base_returns, ds_returns)[0, 1]
|
||||
if np.isfinite(corr):
|
||||
similarities.append(abs(corr))
|
||||
|
||||
if not similarities:
|
||||
return 0.0
|
||||
return float(np.mean(similarities))
|
||||
|
||||
|
||||
def volatility_ratio(close: np.ndarray, fast: int = 12, slow: int = 48) -> float:
|
||||
"""Ratio of short-term to long-term volatility."""
|
||||
returns = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
if len(returns) < slow:
|
||||
return 1.0
|
||||
fast_vol = np.std(returns[-fast:])
|
||||
slow_vol = np.std(returns[-slow:])
|
||||
if slow_vol == 0:
|
||||
return 1.0
|
||||
return float(fast_vol / slow_vol)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Fractal pattern detection and encoding for candlestick sequences."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class CandleType(IntEnum):
|
||||
DOWN = -1
|
||||
DOJI = 0
|
||||
UP = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class FractalPattern:
|
||||
sequence: tuple[CandleType, ...]
|
||||
start_idx: int
|
||||
end_idx: int
|
||||
body_ratios: tuple[float, ...]
|
||||
shadow_ratios: tuple[float, ...]
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self.sequence)
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
m = {CandleType.DOWN: "D", CandleType.DOJI: "0", CandleType.UP: "U"}
|
||||
return "".join(m[c] for c in self.sequence)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.sequence)
|
||||
|
||||
|
||||
def classify_candle(open_: float, close: float, high: float, low: float, doji_threshold: float = 0.1) -> CandleType:
|
||||
body = abs(close - open_)
|
||||
total_range = high - low
|
||||
if total_range == 0:
|
||||
return CandleType.DOJI
|
||||
ratio = body / total_range
|
||||
if ratio < doji_threshold:
|
||||
return CandleType.DOJI
|
||||
return CandleType.UP if close > open_ else CandleType.DOWN
|
||||
|
||||
|
||||
def encode_candles(df: pd.DataFrame, doji_threshold: float = 0.1) -> np.ndarray:
|
||||
types = np.zeros(len(df), dtype=np.int8)
|
||||
body = np.abs(df["close"].values - df["open"].values)
|
||||
total = df["high"].values - df["low"].values
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
ratio = body / total
|
||||
|
||||
types[ratio < doji_threshold] = CandleType.DOJI
|
||||
bullish = (ratio >= doji_threshold) & (df["close"].values > df["open"].values)
|
||||
bearish = (ratio >= doji_threshold) & (df["close"].values <= df["open"].values)
|
||||
types[bullish] = CandleType.UP
|
||||
types[bearish] = CandleType.DOWN
|
||||
return types
|
||||
|
||||
|
||||
def extract_body_ratios(df: pd.DataFrame) -> np.ndarray:
|
||||
body = np.abs(df["close"].values - df["open"].values)
|
||||
total = df["high"].values - df["low"].values
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
return body / total
|
||||
|
||||
|
||||
def extract_shadow_ratios(df: pd.DataFrame) -> np.ndarray:
|
||||
o, c, h, l = df["open"].values, df["close"].values, df["high"].values, df["low"].values
|
||||
upper_shadow = h - np.maximum(o, c)
|
||||
lower_shadow = np.minimum(o, c) - l
|
||||
total = h - l
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
return (upper_shadow - lower_shadow) / total
|
||||
|
||||
|
||||
def find_patterns(df: pd.DataFrame, min_len: int = 3, max_len: int = 6, doji_threshold: float = 0.1) -> list[FractalPattern]:
|
||||
candle_types = encode_candles(df, doji_threshold)
|
||||
body_ratios = extract_body_ratios(df)
|
||||
shadow_ratios = extract_shadow_ratios(df)
|
||||
patterns: list[FractalPattern] = []
|
||||
|
||||
for length in range(min_len, max_len + 1):
|
||||
for i in range(len(df) - length):
|
||||
seq = tuple(CandleType(t) for t in candle_types[i : i + length])
|
||||
br = tuple(body_ratios[i : i + length])
|
||||
sr = tuple(shadow_ratios[i : i + length])
|
||||
patterns.append(FractalPattern(
|
||||
sequence=seq,
|
||||
start_idx=i,
|
||||
end_idx=i + length,
|
||||
body_ratios=br,
|
||||
shadow_ratios=sr,
|
||||
))
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
def pattern_frequency(patterns: list[FractalPattern]) -> pd.DataFrame:
|
||||
from collections import Counter
|
||||
codes = [p.code for p in patterns]
|
||||
counts = Counter(codes)
|
||||
total = len(codes)
|
||||
rows = [
|
||||
{"pattern": code, "count": cnt, "freq": cnt / total, "length": len(code)}
|
||||
for code, cnt in counts.most_common()
|
||||
]
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def normalize_pattern_window(df: pd.DataFrame, start: int, end: int) -> np.ndarray:
|
||||
"""Normalize OHLC window to [0,1] range for comparison."""
|
||||
window = df.iloc[start:end][["open", "high", "low", "close"]].values
|
||||
mn = window.min()
|
||||
mx = window.max()
|
||||
if mx - mn == 0:
|
||||
return np.zeros_like(window)
|
||||
return (window - mn) / (mx - mn)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Fractal similarity measures: DTW, Hausdorff, correlation-based."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.distance import directed_hausdorff
|
||||
from scipy.signal import correlate
|
||||
|
||||
|
||||
def dtw_distance(s1: np.ndarray, s2: np.ndarray) -> float:
|
||||
"""Dynamic Time Warping distance between two 1D sequences."""
|
||||
n, m = len(s1), len(s2)
|
||||
dtw = np.full((n + 1, m + 1), np.inf)
|
||||
dtw[0, 0] = 0.0
|
||||
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
cost = abs(s1[i - 1] - s2[j - 1])
|
||||
dtw[i, j] = cost + min(dtw[i - 1, j], dtw[i, j - 1], dtw[i - 1, j - 1])
|
||||
|
||||
return dtw[n, m]
|
||||
|
||||
|
||||
def hausdorff_distance(s1: np.ndarray, s2: np.ndarray) -> float:
|
||||
"""Hausdorff distance between two OHLC windows (shape Nx4)."""
|
||||
if s1.ndim == 1:
|
||||
s1 = s1.reshape(-1, 1)
|
||||
if s2.ndim == 1:
|
||||
s2 = s2.reshape(-1, 1)
|
||||
d1 = directed_hausdorff(s1, s2)[0]
|
||||
d2 = directed_hausdorff(s2, s1)[0]
|
||||
return max(d1, d2)
|
||||
|
||||
|
||||
def cross_correlation(s1: np.ndarray, s2: np.ndarray) -> float:
|
||||
"""Max normalized cross-correlation between two 1D sequences."""
|
||||
if len(s1) == 0 or len(s2) == 0:
|
||||
return 0.0
|
||||
s1_norm = (s1 - np.mean(s1))
|
||||
s2_norm = (s2 - np.mean(s2))
|
||||
std1, std2 = np.std(s1), np.std(s2)
|
||||
if std1 == 0 or std2 == 0:
|
||||
return 0.0
|
||||
corr = correlate(s1_norm, s2_norm, mode="full")
|
||||
corr /= (std1 * std2 * len(s1))
|
||||
return float(np.max(np.abs(corr)))
|
||||
|
||||
|
||||
def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
|
||||
"""Cosine similarity between two feature vectors."""
|
||||
dot = np.dot(v1, v2)
|
||||
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
|
||||
if n1 == 0 or n2 == 0:
|
||||
return 0.0
|
||||
return float(dot / (n1 * n2))
|
||||
|
||||
|
||||
def pattern_feature_vector(ohlc_window: np.ndarray) -> np.ndarray:
|
||||
"""Extract compact feature vector from normalized OHLC window.
|
||||
|
||||
Features: body ratios, shadow ratios, close-to-close returns,
|
||||
volatility, trend.
|
||||
"""
|
||||
o, h, l, c = ohlc_window[:, 0], ohlc_window[:, 1], ohlc_window[:, 2], ohlc_window[:, 3]
|
||||
total = h - l
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
|
||||
body = np.abs(c - o) / total
|
||||
upper_shadow = (h - np.maximum(o, c)) / total
|
||||
lower_shadow = (np.minimum(o, c) - l) / total
|
||||
returns = np.diff(c) / np.where(c[:-1] == 0, 1e-10, c[:-1])
|
||||
|
||||
features = np.concatenate([
|
||||
body,
|
||||
upper_shadow,
|
||||
lower_shadow,
|
||||
returns,
|
||||
[np.std(returns) if len(returns) > 0 else 0],
|
||||
[c[-1] - c[0]],
|
||||
])
|
||||
return features
|
||||
Reference in New Issue
Block a user