integra(TP01): merge ricerca branch strategy-research-2026-06 (squash) — strategia vincente + harness + track A-E
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py (le 5 track della ricerca)
- scripts/live/paper_trend.py paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)
Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""TRACK D on DIFFERENT TIMEFRAMES — per-year PnL and per-year max drawdown.
|
||||
|
||||
Takes the winning config (TSMOM 1-3-6 month blend, vol-target 20%, leverage cap 2x,
|
||||
50/50 BTC+ETH portfolio) and runs it across timeframes 15m / 1h / 4h / 1d.
|
||||
|
||||
Honesty preserved: same building blocks as trackD_trendport.py (positions shifted +1 bar,
|
||||
fee 0.10% RT on turnover, vol-targeting on past-only realized vol). Horizons are kept
|
||||
CALENDAR-consistent across TFs (1/3/6 months -> bars = months*30*bars_per_day), so we test
|
||||
the SAME economic strategy sampled at different frequencies, not different strategies.
|
||||
|
||||
4h/1d are RESAMPLED from the certified 1h feed (00:00 UTC boundaries).
|
||||
|
||||
Run: uv run python scripts/research/trackD_timing.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from scripts.research.trackD_trendport import (
|
||||
simple_returns, realized_vol, sig_tsmom_blend, build_target,
|
||||
equity_from_target,
|
||||
)
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||
TARGET_VOL = 0.20
|
||||
LEVERAGE = 2.0
|
||||
|
||||
# timeframe -> (load_tf, resample_rule_or_None, bars_per_day)
|
||||
TIMEFRAMES = {
|
||||
"15m": ("15m", None, 96),
|
||||
"1h": ("1h", None, 24),
|
||||
"4h": ("1h", "4h", 6),
|
||||
"1d": ("1h", "1D", 1),
|
||||
}
|
||||
|
||||
|
||||
def resample_ohlc(df: pd.DataFrame, rule: str) -> pd.DataFrame:
|
||||
g = df.copy()
|
||||
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
idx.name = "dt"
|
||||
g.index = idx
|
||||
out = g.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
out = out.dropna(subset=["open"])
|
||||
out["datetime"] = out.index
|
||||
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||
|
||||
|
||||
def get_df(tf_key: str, asset: str) -> pd.DataFrame:
|
||||
load_tf, rule, _ = TIMEFRAMES[tf_key]
|
||||
df = load(asset, load_tf)
|
||||
if rule:
|
||||
df = resample_ohlc(df, rule)
|
||||
return df
|
||||
|
||||
|
||||
def run_asset(df, bars_per_day, target_vol=TARGET_VOL, leverage=LEVERAGE,
|
||||
long_only=False, fee_side=FEE_SIDE):
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bars_per_day * 365.25
|
||||
# recompute building blocks at this TF's bar frequency
|
||||
h1, h3, h6 = 30 * bars_per_day, 90 * bars_per_day, 180 * bars_per_day
|
||||
vol_win = 30 * bars_per_day
|
||||
# realized_vol / tsmom use BARS_PER_YEAR from trackD (1h) for annualization of vol;
|
||||
# we must annualize with THIS tf's bpy -> compute vol locally
|
||||
vol = pd.Series(r).rolling(vol_win, min_periods=vol_win // 2).std().values * np.sqrt(bpy)
|
||||
direction = sig_tsmom_blend(c, horizons=(h1, h3, h6))
|
||||
tgt = build_target(direction, vol, target_vol, leverage, long_only)
|
||||
equity, net = equity_from_target(tgt, r, fee_side)
|
||||
# discrete position SIGN for trade counting (entry = sign change to a new non-zero state)
|
||||
sign = np.sign(tgt)
|
||||
return dict(net=net, ts=df["datetime"], equity=equity, bpy=bpy, sign=sign, target=tgt)
|
||||
|
||||
|
||||
def portfolio_series(sleeves):
|
||||
a = pd.Series(sleeves["BTC"]["net"], index=pd.to_datetime(sleeves["BTC"]["ts"].values))
|
||||
b = pd.Series(sleeves["ETH"]["net"], index=pd.to_datetime(sleeves["ETH"]["ts"].values))
|
||||
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * j["a"].values + 0.5 * j["b"].values
|
||||
idx = pd.to_datetime(j.index)
|
||||
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
return idx, combo, equity
|
||||
|
||||
|
||||
def overall_metrics(idx, combo, equity, bpy):
|
||||
rr = combo[np.isfinite(combo)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25
|
||||
total = equity[-1] / equity[0]
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1, daily_2k=daily_2k)
|
||||
|
||||
|
||||
def per_year(idx, equity):
|
||||
"""Return {year: (pnl_pct, maxdd_pct)} where maxdd is the worst drawdown WITHIN the year."""
|
||||
eq = pd.Series(equity, index=idx)
|
||||
out = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) < 2:
|
||||
continue
|
||||
pnl = g.iloc[-1] / g.iloc[0] - 1.0
|
||||
v = g.values
|
||||
peak = np.maximum.accumulate(v)
|
||||
ddy = float(np.max((peak - v) / peak))
|
||||
out[int(y)] = (float(pnl), ddy)
|
||||
return out
|
||||
|
||||
|
||||
def trades_per_year(sleeves):
|
||||
"""Count entries per year, summed across both sleeves. An 'entry' = the position SIGN
|
||||
changing to a new non-zero value (flat->long, flat->short, or a direction flip)."""
|
||||
counts: dict[int, int] = {}
|
||||
for a in ASSETS:
|
||||
sign = sleeves[a]["sign"]
|
||||
ts = pd.to_datetime(sleeves[a]["ts"].values)
|
||||
for i in range(1, len(sign)):
|
||||
s, prev = sign[i], sign[i - 1]
|
||||
if s != 0 and s != prev: # entry: from flat or opposite into a non-zero state
|
||||
counts[ts[i].year] = counts.get(ts[i].year, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
ALL_YEARS = list(range(2018, 2027))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 118)
|
||||
print("# TRACK D WINNER ACROSS TIMEFRAMES — TSMOM 1-3-6m blend, vol-target 20%, lev 2x, 50/50 BTC+ETH")
|
||||
print("# fee 0.10% RT on turnover, positions +1 bar (no look-ahead). 4h/1d resampled from certified 1h.")
|
||||
print("=" * 118)
|
||||
|
||||
for mode_long_only, mode_name in ((False, "LONG-SHORT"), (True, "LONG-FLAT")):
|
||||
print("\n" + "#" * 118)
|
||||
print(f"# MODE = {mode_name}")
|
||||
print("#" * 118)
|
||||
for tf_key in TIMEFRAMES:
|
||||
bpd = TIMEFRAMES[tf_key][2]
|
||||
sleeves = {a: run_asset(get_df(tf_key, a), bpd, long_only=mode_long_only)
|
||||
for a in ASSETS}
|
||||
idx, combo, equity = portfolio_series(sleeves)
|
||||
ov = overall_metrics(idx, combo, equity, sleeves["BTC"]["bpy"])
|
||||
py = per_year(idx, equity)
|
||||
|
||||
tpy = trades_per_year(sleeves)
|
||||
total_trades = sum(tpy.values())
|
||||
print(f"\n ── TF {tf_key:<3s} │ ret {ov['total']*100:>+8.0f}% CAGR {ov['cagr']*100:>+6.1f}% "
|
||||
f"Sharpe {ov['sharpe']:>4.2f} maxDD {ov['max_dd']*100:>4.1f}% "
|
||||
f"€/day(2k) {ov['daily_2k']:>+6.2f} trades {total_trades}")
|
||||
# per-year PnL / DD / trades rows
|
||||
print(f" {'PnL %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][0]*100:>+7.0f}") for y in ALL_YEARS))
|
||||
print(f" {'maxDD %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][1]*100:>7.1f}") for y in ALL_YEARS))
|
||||
print(f" {'trades':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{tpy.get(y,0):>7d}") for y in ALL_YEARS))
|
||||
# year header for reference
|
||||
print("\n " + "year ".ljust(8) + "".join(f"{y:>7d}" for y in ALL_YEARS))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user