chore(analysis): dedup engine gate PORT06 + drift monitor giornaliero + impact bfill
- _port06_gate_common.py: build_trades_variant/equity_from_trades/port_metrics/dd fattorizzati dai 3 gate exit16/trendmax/dip01 (-214 righe duplicate). Nessun copy-drift trovato; versione promossa = trendmax (superset con hurst_mask). Output dei 3 gate verificato BYTE-IDENTICO prima/dopo. dip_trades resta nel suo script (sibling deliberato long-only/orig_gap, non una copia). - drift_monitor.py: rolling-return per famiglia vs distribuzione storica propria (warn sotto p5; oggi: FADE 120g al p2). In crontab host giornaliero 07:15 UTC con report Telegram. Osservabilita', non filtro di trading. - daily_equity_bfill_impact.py: bug bfill _daily_equity QUANTIFICATO -> non materiale (OOS invariato per costruzione, FULL DD 3.46->3.67 col fix, nessun verdetto gate a rischio). Lasciato documentato in TODO, niente fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+16
-8
@@ -41,19 +41,27 @@
|
|||||||
|
|
||||||
- [x] ~~forming-bar su ROT02/TSM01~~ — GIÀ FIXATO (v1.1.10, 2026-06-08): `_panel` condiviso
|
- [x] ~~forming-bar su ROT02/TSM01~~ — GIÀ FIXATO (v1.1.10, 2026-06-08): `_panel` condiviso
|
||||||
scarta la barra in formazione via `last_bar_is_forming`. Item rimasto stantio nel TODO.
|
scarta la barra in formazione via `last_bar_is_forming`. Item rimasto stantio nel TODO.
|
||||||
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo
|
- [ ] **ancora bfill di `_daily_equity`** — QUANTIFICATO 2026-06-11 (`daily_equity_bfill_impact.py`):
|
||||||
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le
|
**NON materiale, lasciare documentato**. PORT06 OOS invariato per costruzione (il bias vive
|
||||||
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se
|
in testa alla serie, pre-SPLIT; ΔSharpe +0.001, ΔDD 0.000); FULL DD leggermente OTTIMISTICO
|
||||||
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel
|
(3.46→3.67 col fix: il primo trade DIP01 2021, −3.75%, è nascosto dal bfill). Nessun verdetto
|
||||||
gate DIP01 (parità inizialmente fallita per questo).
|
di gate a rischio (bias identico nei due bracci A-vs-B, si cancella). Unica eccezione OOS:
|
||||||
|
TSM01 (primo punto equity post-SPLIT, −0.014 Sh). Correggere SOLO in un eventuale refactor
|
||||||
|
del builder daily, OVUNQUE in un colpo (~12 file di gate replicano la convenzione) e
|
||||||
|
ri-baselinando i canonici nello stesso commit. CAVEAT per gate futuri: finestre IDX che
|
||||||
|
partono a metà storia amplificano il bug → usare lì la convenzione carry-forward corretta.
|
||||||
- [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)`
|
- [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)`
|
||||||
sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del
|
sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del
|
||||||
capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico).
|
capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico).
|
||||||
Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs
|
Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs
|
||||||
media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`.
|
media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`.
|
||||||
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant`
|
- [x] ~~engine duplicato nei 3 gate~~ — FATTO (2026-06-11): fattorizzato in
|
||||||
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la
|
`scripts/analysis/_port06_gate_common.py` (`build_trades_variant` versione trendmax =
|
||||||
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso.
|
superset con hurst_mask/trend_max parametrici, `equity_from_trades`, `port_metrics`,
|
||||||
|
`dd`); i 3 gate importano da lì. Nessuna drift di matematica trovata fra le copie
|
||||||
|
(solo firme/docstring). Output dei 3 gate verificato BYTE-IDENTICO prima/dopo.
|
||||||
|
`dip_trades` (DIP01) NON è una copia ma un sibling deliberato (long-only, orig_gap,
|
||||||
|
j clampato) → resta nel suo script, documentato nel modulo comune.
|
||||||
- [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE):
|
- [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE):
|
||||||
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
|
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""REGRESSION-LOCK COMUNE dei gate PORT06 live (exit16 / trendmax / dip01).
|
||||||
|
|
||||||
|
Queste funzioni erano copiate quasi-verbatim in exit16_port06_impact.py,
|
||||||
|
trendmax_port06_impact.py e dip01_exit16_impact.py. Sono il regression-lock
|
||||||
|
delle DECISIONI LIVE (EXIT-16, swap hurst->trend, DIP01 EXIT-16): la copy-drift
|
||||||
|
fra le copie avrebbe corrotto i verdetti, quindi vivono qui in un'unica copia.
|
||||||
|
|
||||||
|
NON cambiare la matematica: i gate devono restare riproducibili byte-a-byte.
|
||||||
|
Se un nuovo gate richiede un comportamento diverso, PARAMETRIZZARE (come fu
|
||||||
|
fatto per hurst_mask/trend_max), mai biforcare una copia.
|
||||||
|
|
||||||
|
Contenuto:
|
||||||
|
build_trades_variant : replay ESATTO di risk_management.build_trades sulle
|
||||||
|
fade (mode="orig" == canonico), con i rami varianti
|
||||||
|
EXIT-16 (mode="exit16"), filtro trend (trend_max) e
|
||||||
|
loss-guard Hurst (hurst_mask) parametrici.
|
||||||
|
equity_from_trades : trade -> equity giornaliera normalizzata su IDX
|
||||||
|
(stesso flusso di combine_portfolio.fade_daily_equity).
|
||||||
|
port_metrics : metriche FULL/OOS del portafoglio con la STESSA
|
||||||
|
matematica pesi di Portfolio.backtest (weight_vector
|
||||||
|
su tutti gli sleeve, ribilancio come port_returns).
|
||||||
|
dd : max drawdown % di una equity.
|
||||||
|
|
||||||
|
NB: l'engine DIP01 (dip_trades in dip01_exit16_impact.py) NON e' una copia di
|
||||||
|
build_trades_variant ma un sibling deliberatamente diverso (long-only, mode
|
||||||
|
"orig_gap" gap-aware, j clampato a n-1 a fine serie, niente filtri trend/hurst)
|
||||||
|
-> resta nel suo script.
|
||||||
|
"""
|
||||||
|
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.strategy_research import atr
|
||||||
|
from scripts.analysis.risk_management import FEE_RT, LEV, POS, INIT
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
_norm, IDX, port_returns, metrics, SPLIT,
|
||||||
|
)
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
|
||||||
|
EMA_LONG = 200
|
||||||
|
|
||||||
|
|
||||||
|
def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
|
||||||
|
buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
|
||||||
|
"""Replica ESATTA di risk_management.build_trades, con i rami varianti.
|
||||||
|
|
||||||
|
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
||||||
|
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
|
||||||
|
SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
|
||||||
|
trend_max : None = filtro OFF; 3.0 = config live.
|
||||||
|
hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard storico).
|
||||||
|
"""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||||
|
fee = fee_rt * lev
|
||||||
|
out = []
|
||||||
|
last = -1
|
||||||
|
for e in ents:
|
||||||
|
i, d = e["i"], e["d"]
|
||||||
|
if i <= last or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
if hurst_mask is not None and hurst_mask[i]:
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]
|
||||||
|
j = min(i + mb, n - 1)
|
||||||
|
for k in range(1, mb + 1):
|
||||||
|
j = i + k
|
||||||
|
if j >= n:
|
||||||
|
exit_p = c[n - 1]
|
||||||
|
break
|
||||||
|
if mode == "orig":
|
||||||
|
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hs:
|
||||||
|
exit_p = sl0
|
||||||
|
break
|
||||||
|
if ht:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
else: # exit16
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if ht:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||||
|
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
||||||
|
(d == -1 and c[j] > sl0 + buffer * aj)
|
||||||
|
if confirm:
|
||||||
|
exit_p = c[j]
|
||||||
|
break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * lev - fee
|
||||||
|
out.append((i, j, ret))
|
||||||
|
last = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def equity_from_trades(df, trades):
|
||||||
|
"""Trade -> equity giornaliera su IDX (flusso di combine_portfolio.fade_daily_equity)."""
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
n = len(df)
|
||||||
|
eq = np.full(n, INIT, dtype=float)
|
||||||
|
cap = INIT
|
||||||
|
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||||
|
cap = max(cap + cap * POS * ret, 10.0)
|
||||||
|
eq[j:] = cap
|
||||||
|
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(s)
|
||||||
|
|
||||||
|
|
||||||
|
def port_metrics(members: dict[str, pd.Series], p):
|
||||||
|
"""Metriche (FULL, OOS) del portafoglio p con la STESSA matematica pesi cap
|
||||||
|
di Portfolio.backtest."""
|
||||||
|
ids = p.sleeve_ids
|
||||||
|
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||||
|
drp = port_returns({i: members[i] for i in ids}, w)
|
||||||
|
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def dd(s):
|
||||||
|
"""Max drawdown % di una serie equity."""
|
||||||
|
pk = s.cummax()
|
||||||
|
return float(((pk - s) / pk).max() * 100)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""ANALISI DI IMPATTO (sola lettura, da docs/TODO.md): bug bfill di `_daily_equity`.
|
||||||
|
|
||||||
|
IL BUG (scripts/analysis/honest_improve2.py:30):
|
||||||
|
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
|
||||||
|
La serie `s` e' a PUNTI-TRADE (un valore di capitale per ogni exit). Il `reindex(idx)`
|
||||||
|
taglia PRIMA di forward-fillare: i giorni di IDX precedenti al primo trade DENTRO la
|
||||||
|
finestra restano NaN (il ffill non ha un valore precedente in-finestra da propagare) e
|
||||||
|
il `.bfill()` finale li riempie col capitale DOPO il primo trade in-finestra. Effetti:
|
||||||
|
1. l'ancora a idx[0] e' il capitale post-primo-trade-in-finestra, NON il capitale
|
||||||
|
portato avanti dall'ultimo trade PRIMA della finestra;
|
||||||
|
2. il rendimento del primo trade in-finestra viene CANCELLATO dalla serie daily
|
||||||
|
(la testa e' piatta al valore post-trade -> pct_change = 0 anche il giorno del trade).
|
||||||
|
|
||||||
|
CORREZIONE (qui, solo per confronto): ffill PRIMA del reindex (carry-forward su tutta la
|
||||||
|
storia trade) + testa pre-primo-trade-assoluto = capitale iniziale 1000. MAI valori dal futuro.
|
||||||
|
|
||||||
|
Sleeve canonici interessati (serie a punti-trade -> testa di IDX scoperta):
|
||||||
|
DIP01_BTC, PR_ETHBTC, PR_ETHBTC_15M, PR_LTCETH, PR_ADAETH, PR_BTCLTC, PR_ETHSOL,
|
||||||
|
TSM01, XS01 (questi due quasi-densi: punti daily/12h -> impatto atteso ~0).
|
||||||
|
TR01_basket / ROT02_rot passano da _daily_equity ma con punti PER-BARRA (densi dal
|
||||||
|
2018) -> verificati comunque qui via monkeypatch runtime (nessun file canonico toccato).
|
||||||
|
I fade (combine_portfolio.py:52) e SH01 (shape_ml_validate.py:124) usano lo stesso
|
||||||
|
pattern reindex+bfill ma su equity PER-BARRA con dati che iniziano prima di IDX[0]
|
||||||
|
-> il bfill e' un no-op (verificato: nessun NaN in testa).
|
||||||
|
|
||||||
|
NB: le metriche OOS canoniche affettano la STESSA serie daily a SPLIT (metrics(dr,
|
||||||
|
lo=SPLIT)); la distorsione sta solo in testa (2021) -> l'OOS e' invariato per
|
||||||
|
costruzione se il primo trade in-finestra precede lo SPLIT. Questo script lo misura.
|
||||||
|
|
||||||
|
Uso: uv run python scripts/analysis/daily_equity_bfill_impact.py
|
||||||
|
"""
|
||||||
|
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))
|
||||||
|
|
||||||
|
import scripts.analysis.honest_improve2 as hi2
|
||||||
|
from scripts.analysis.honest_improve2 import _norm, dip_market_gated
|
||||||
|
from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns
|
||||||
|
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
|
||||||
|
from scripts.analysis.tsmom_research import tsmom_sim
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIR_DEFS
|
||||||
|
from scripts.strategies.XS01_cross_sectional import xsec_sim
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
INIT = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- le due convenzioni ----------------
|
||||||
|
def daily_equity_buggy(ts_list, cap_list, idx):
|
||||||
|
"""Replica ESATTA di honest_improve2._daily_equity (per parity-check)."""
|
||||||
|
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
|
||||||
|
s = s[~s.index.duplicated(keep="last")].sort_index()
|
||||||
|
return s.resample("1D").last().reindex(idx).ffill().bfill()
|
||||||
|
|
||||||
|
|
||||||
|
def daily_equity_fixed(ts_list, cap_list, idx, init=INIT):
|
||||||
|
"""CORRETTA: ancora = capitale portato avanti dall'ultimo trade PRIMA della
|
||||||
|
finestra (ffill prima del reindex); pre-primo-trade assoluto = capitale iniziale."""
|
||||||
|
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
|
||||||
|
s = s[~s.index.duplicated(keep="last")].sort_index()
|
||||||
|
daily = s.resample("1D").last().ffill() # carry-forward su TUTTA la storia
|
||||||
|
daily = daily.reindex(idx).ffill() # coda oltre l'ultimo trade
|
||||||
|
return daily.fillna(init) # testa pre-primo-trade: capitale iniziale
|
||||||
|
|
||||||
|
|
||||||
|
def head_info(ts_list, cap_list, idx):
|
||||||
|
"""(primo giorno con trade dentro IDX, rendimento di testa perso dal bfill %)."""
|
||||||
|
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
|
||||||
|
s = s[~s.index.duplicated(keep="last")].sort_index()
|
||||||
|
raw = s.resample("1D").last().reindex(idx) # senza fill: NaN = nessun trade quel giorno
|
||||||
|
first = raw.first_valid_index()
|
||||||
|
if first is None:
|
||||||
|
return None, 0.0
|
||||||
|
fixed = daily_equity_fixed(ts_list, cap_list, idx)
|
||||||
|
lost = (fixed.loc[first] / fixed.iloc[0] - 1) * 100 # ritorno idx[0]->primo trade-day
|
||||||
|
return first.date(), float(lost)
|
||||||
|
|
||||||
|
|
||||||
|
def m2(eq: pd.Series):
|
||||||
|
dr = eq.pct_change().fillna(0.0)
|
||||||
|
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_pair(label, b, f):
|
||||||
|
d_sh = f["sharpe"] - b["sharpe"]
|
||||||
|
d_dd = f["dd"] - b["dd"]
|
||||||
|
d_rt = f["ret"] - b["ret"]
|
||||||
|
return (f" {label:<22s}"
|
||||||
|
f"Sh {b['sharpe']:6.2f}->{f['sharpe']:6.2f} ({d_sh:+.3f}) "
|
||||||
|
f"DD {b['dd']:6.2f}->{f['dd']:6.2f} ({d_dd:+.3f}pp) "
|
||||||
|
f"ret {b['ret']:+9.1f}->{f['ret']:+9.1f} ({d_rt:+8.2f}pp)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 110)
|
||||||
|
print(" IMPATTO bug bfill _daily_equity (honest_improve2.py:30) — attuale vs corretto")
|
||||||
|
print(f" IDX {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE} (slice a SPLIT={SPLIT} sui rendimenti daily)")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
# ---------------- [1] baseline canonica (bfill cosi' com'e') ----------------
|
||||||
|
print("\n[1] build_everything() canonico (2-3 min)...")
|
||||||
|
from scripts.analysis.report_families import build_everything
|
||||||
|
S, pairs, tsm, shape = build_everything()
|
||||||
|
base = {**S, **pairs, **tsm, **shape}
|
||||||
|
|
||||||
|
# ---------------- [2] ri-simula gli sleeve a punti-trade ----------------
|
||||||
|
print("[2] ri-simulazione sleeve a punti-trade (parity-check + versione corretta)...")
|
||||||
|
raw: dict[str, tuple] = {}
|
||||||
|
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||||
|
raw["DIP01_BTC"] = (d["eq_ts"], d["eq_v"])
|
||||||
|
for a, b_, p in PAIR_DEFS:
|
||||||
|
r = pairs_sim(a, b_, **p)
|
||||||
|
raw[f"PR_{a}{b_}"] = (r["eq_ts"], r["eq_v"])
|
||||||
|
r15 = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
|
||||||
|
max_bars=35, flat_skip=True, pos=0.075)
|
||||||
|
raw["PR_ETHBTC_15M"] = (r15["eq_ts"], r15["eq_v"])
|
||||||
|
t = tsmom_sim()
|
||||||
|
raw["TSM01"] = (t["eq_ts"], t["eq_v"])
|
||||||
|
x = xsec_sim()
|
||||||
|
raw["XS01"] = (x["eq_ts"], x["eq_v"])
|
||||||
|
|
||||||
|
fixed: dict[str, pd.Series] = {}
|
||||||
|
print(f"\n {'sleeve':<16s}{'parity(max|diff|)':>18s}{'1o trade in IDX':>17s}{'ret testa perso%':>18s}")
|
||||||
|
for k, (ts, v) in raw.items():
|
||||||
|
bug = _norm(daily_equity_buggy(ts, v, IDX))
|
||||||
|
par = float((bug - base[k]).abs().max())
|
||||||
|
fixed[k] = _norm(daily_equity_fixed(ts, v, IDX))
|
||||||
|
first, lost = head_info(ts, v, IDX)
|
||||||
|
flag = "" if par < 1e-9 else " <-- PARITY FAIL"
|
||||||
|
print(f" {k:<16s}{par:>18.2e}{str(first):>17s}{lost:>+18.3f}{flag}")
|
||||||
|
|
||||||
|
# TR01/ROT02: passano da _daily_equity ma con punti per-barra (densi) ->
|
||||||
|
# ricalcolo con monkeypatch RUNTIME della funzione (nessun file toccato).
|
||||||
|
orig_de = hi2._daily_equity
|
||||||
|
try:
|
||||||
|
hi2._daily_equity = daily_equity_fixed
|
||||||
|
tr_f = _norm(hi2._tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
|
||||||
|
rot_f = _norm(hi2._rot_daily_equity(IDX))
|
||||||
|
finally:
|
||||||
|
hi2._daily_equity = orig_de
|
||||||
|
for k, sf in (("TR01_basket", tr_f), ("ROT02_rot", rot_f)):
|
||||||
|
diff = float((sf - base[k]).abs().max())
|
||||||
|
print(f" {k:<16s}{'(denso)':>18s}{'—':>17s}{diff:>18.2e} (diff fixed-vs-base: atteso ~0)")
|
||||||
|
fixed[k] = sf
|
||||||
|
|
||||||
|
# ---------------- [3] metriche per sleeve: attuale vs corretto ----------------
|
||||||
|
print("\n" + "=" * 110)
|
||||||
|
print(" (3) SLEEVE a punti-trade — FULL e OOS, attuale(bfill) -> corretto(carry-forward)")
|
||||||
|
print("=" * 110)
|
||||||
|
rows_oos_delta = {}
|
||||||
|
for k in fixed:
|
||||||
|
bf, bo = m2(base[k])
|
||||||
|
ff, fo = m2(fixed[k])
|
||||||
|
print(fmt_pair(f"{k} FULL", bf, ff))
|
||||||
|
print(fmt_pair(f"{k} OOS ", bo, fo))
|
||||||
|
rows_oos_delta[k] = (ff["sharpe"] - bf["sharpe"], ff["dd"] - bf["dd"],
|
||||||
|
fo["sharpe"] - bo["sharpe"], fo["dd"] - bo["dd"])
|
||||||
|
|
||||||
|
# ---------------- [4] PORT06: attuale vs corretto ----------------
|
||||||
|
print("\n" + "=" * 110)
|
||||||
|
print(" (4) PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) — attuale vs corretto")
|
||||||
|
print("=" * 110)
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
|
||||||
|
def port_m(members):
|
||||||
|
ids = p.sleeve_ids
|
||||||
|
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||||
|
drp = port_returns({i: members[i] for i in ids}, w)
|
||||||
|
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||||
|
|
||||||
|
members_fix = {**base, **fixed}
|
||||||
|
bf, bo = port_m(base)
|
||||||
|
ff, fo = port_m(members_fix)
|
||||||
|
print(fmt_pair("PORT06 FULL", bf, ff))
|
||||||
|
print(fmt_pair("PORT06 OOS ", bo, fo))
|
||||||
|
|
||||||
|
# ---------------- [5] verdetto ----------------
|
||||||
|
print("\n" + "=" * 110)
|
||||||
|
print(" (5) VERDETTO (soglie materialita': >0.1 Sharpe o >0.5pp DD su PORT06 OOS)")
|
||||||
|
print("=" * 110)
|
||||||
|
d_sh_oos = abs(fo["sharpe"] - bo["sharpe"])
|
||||||
|
d_dd_oos = abs(fo["dd"] - bo["dd"])
|
||||||
|
d_sh_full = abs(ff["sharpe"] - bf["sharpe"])
|
||||||
|
d_dd_full = abs(ff["dd"] - bf["dd"])
|
||||||
|
materiale = d_sh_oos > 0.1 or d_dd_oos > 0.5
|
||||||
|
print(f" PORT06 OOS : dSharpe {fo['sharpe']-bo['sharpe']:+.4f} dDD {fo['dd']-bo['dd']:+.4f}pp"
|
||||||
|
f" -> {'MATERIALE' if materiale else 'NON materiale'}")
|
||||||
|
print(f" PORT06 FULL: dSharpe {ff['sharpe']-bf['sharpe']:+.4f} dDD {ff['dd']-bf['dd']:+.4f}pp")
|
||||||
|
worst = sorted(rows_oos_delta.items(), key=lambda kv: -abs(kv[1][0]) - abs(kv[1][1]) / 10)
|
||||||
|
print(" Sleeve piu' toccati (dSharpe FULL, dDD FULL, dSharpe OOS, dDD OOS):")
|
||||||
|
for k, (ds, dd_, dso, ddo) in worst[:5]:
|
||||||
|
print(f" {k:<16s} FULL {ds:+.3f} / {dd_:+.3f}pp OOS {dso:+.3f} / {ddo:+.3f}pp")
|
||||||
|
print("\n Nota strutturale: l'OOS canonico e' uno slice a SPLIT della stessa serie daily;")
|
||||||
|
print(" la distorsione bfill vive solo in testa (prima del primo trade in IDX) -> se il")
|
||||||
|
print(" primo trade in-finestra precede lo SPLIT, l'OOS e' INVARIATO per costruzione.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -33,11 +33,9 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
from src.data.downloader import load_data
|
from src.data.downloader import load_data
|
||||||
from scripts.analysis.strategy_research import atr
|
from scripts.analysis.strategy_research import atr
|
||||||
from scripts.analysis.combine_portfolio import (
|
from scripts.analysis.combine_portfolio import _norm, IDX, metrics, SPLIT, OOS_DATE
|
||||||
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
from scripts.analysis._port06_gate_common import port_metrics
|
||||||
)
|
|
||||||
from scripts.portfolios._defs import PORTFOLIOS
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
from src.portfolio import weighting as W
|
|
||||||
|
|
||||||
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
|
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
|
||||||
BUFFER = 0.5
|
BUFFER = 0.5
|
||||||
@@ -139,15 +137,6 @@ def cell_metrics(eq):
|
|||||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
def port_metrics(members, p):
|
|
||||||
ids = p.sleeve_ids
|
|
||||||
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
|
||||||
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
|
||||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
|
||||||
drp = port_returns({i: members[i] for i in ids}, w)
|
|
||||||
return metrics(drp), metrics(drp, lo=SPLIT)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = PORTFOLIOS["PORT06"]
|
p = PORTFOLIOS["PORT06"]
|
||||||
print("=" * 104)
|
print("=" * 104)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Drift monitor per-famiglia — il rolling-return corrente di ogni famiglia vs la
|
||||||
|
DISTRIBUZIONE STORICA dei propri rolling-return (stessa finestra, storia 2021+).
|
||||||
|
|
||||||
|
Non è un filtro di trading: è OSSERVABILITÀ (la protezione giusta contro il drift è
|
||||||
|
accorgersene presto, non ritoccare i parametri — lezione 2026-06-11: le FADE al 2°
|
||||||
|
percentile sul 120g sono state trovate a mano; questo script lo rende ripetibile).
|
||||||
|
|
||||||
|
Percentile basso = la famiglia sta attraversando uno dei suoi tratti peggiori:
|
||||||
|
- sotto P_WARN (5%): segnalato — coerente con la coda storica, OSSERVARE;
|
||||||
|
- il PORT06 complessivo sotto P_WARN è più serio (la diversificazione non copre).
|
||||||
|
Equity dal builder canonico (all_sleeve_equities → parità coi gate).
|
||||||
|
|
||||||
|
uv run python scripts/analysis/drift_monitor.py # stampa
|
||||||
|
uv run python scripts/analysis/drift_monitor.py --telegram # + invio Telegram
|
||||||
|
"""
|
||||||
|
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.combine_portfolio import port_returns
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
WINDOWS = (60, 120) # giorni
|
||||||
|
P_WARN = 5.0 # percentile sotto cui segnalare
|
||||||
|
|
||||||
|
|
||||||
|
def family_returns():
|
||||||
|
"""Rendimenti daily per famiglia (equal-weight intra-famiglia) + PORT06 (pesi cap)."""
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
eq = dict(all_sleeve_equities())
|
||||||
|
ids = list(p.sleeve_ids)
|
||||||
|
fams: dict[str, list] = {}
|
||||||
|
for i in ids:
|
||||||
|
fams.setdefault(W.family_of(i), []).append(i)
|
||||||
|
out = {}
|
||||||
|
for f, members in sorted(fams.items()):
|
||||||
|
out[f] = port_returns({i: eq[i] for i in members},
|
||||||
|
{i: 1 / len(members) for i in members})
|
||||||
|
dr = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w = W.weight_vector("cap", ids, dr, caps=p.caps, clusters=p.clusters)
|
||||||
|
out["PORT06"] = port_returns({i: eq[i] for i in ids}, w)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def drift_rows():
|
||||||
|
rows = []
|
||||||
|
for name, r in family_returns().items():
|
||||||
|
for win in WINDOWS:
|
||||||
|
roll = (1 + r).rolling(win).apply(np.prod, raw=True) - 1
|
||||||
|
roll = roll.dropna()
|
||||||
|
if len(roll) < 100:
|
||||||
|
continue
|
||||||
|
cur = float(roll.iloc[-1])
|
||||||
|
pct = float((roll < cur).mean() * 100)
|
||||||
|
rows.append(dict(name=name, win=win, cur=cur * 100, pct=pct,
|
||||||
|
p5=float(roll.quantile(0.05) * 100),
|
||||||
|
med=float(roll.median() * 100)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(rows) -> tuple[str, bool]:
|
||||||
|
warn = [r for r in rows if r["pct"] < P_WARN]
|
||||||
|
L = ["📉 <b>Drift monitor</b> — rolling-return vs storia propria (2021+)"]
|
||||||
|
L.append("<pre>" + f"{'famiglia':<9}{'win':>5}{'corr%':>8}{'pct':>6}{'p5%':>8}{'med%':>7}")
|
||||||
|
for r in rows:
|
||||||
|
flag = " ⚠️" if r["pct"] < P_WARN else ""
|
||||||
|
L.append(f"{r['name']:<9}{r['win']:>4}g{r['cur']:>+8.1f}{r['pct']:>5.0f}%"
|
||||||
|
f"{r['p5']:>+8.1f}{r['med']:>+7.1f}{flag}")
|
||||||
|
L.append("</pre>")
|
||||||
|
if warn:
|
||||||
|
names = ", ".join(f"{r['name']} {r['win']}g (p{r['pct']:.0f})" for r in warn)
|
||||||
|
L.append(f"⚠️ sotto il p{P_WARN:.0f} storico: {names} — coda storica della famiglia: "
|
||||||
|
"OSSERVARE, non ritoccare i parametri (drift ≠ rottura; "
|
||||||
|
"vedi docs/diary/2026-06-11-stability-sweep.md)")
|
||||||
|
else:
|
||||||
|
L.append(f"✅ tutte le famiglie sopra il p{P_WARN:.0f} storico")
|
||||||
|
return "\n".join(L), bool(warn)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = drift_rows()
|
||||||
|
report, warned = build_report(rows)
|
||||||
|
import re
|
||||||
|
print(re.sub(r"</?(b|pre)>", "", report))
|
||||||
|
if "--telegram" in sys.argv:
|
||||||
|
from src.live.telegram_notifier import send_telegram
|
||||||
|
ok = send_telegram(report)
|
||||||
|
print(f"[telegram] inviato: {ok}")
|
||||||
|
return warned
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -24,106 +24,23 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from src.data.downloader import load_data
|
from src.data.downloader import load_data
|
||||||
from scripts.analysis.strategy_research import atr
|
from scripts.analysis.risk_management import strats_for
|
||||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
from scripts.analysis.combine_portfolio import OOS_DATE
|
||||||
from scripts.analysis.combine_portfolio import (
|
from scripts.analysis._port06_gate_common import (
|
||||||
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
|
||||||
)
|
)
|
||||||
from scripts.portfolios._defs import PORTFOLIOS
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
from src.portfolio import weighting as W
|
|
||||||
|
|
||||||
BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- engine replay
|
|
||||||
def build_trades_variant(ents, df, mode, buffer=BUFFER,
|
|
||||||
lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
|
|
||||||
"""Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
|
|
||||||
|
|
||||||
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
|
||||||
mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
|
|
||||||
long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
|
|
||||||
short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
|
|
||||||
TP intrabar al livello e max_bars al close INVARIATI.
|
|
||||||
"""
|
|
||||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
|
||||||
n = len(c)
|
|
||||||
a = atr(df, 14)
|
|
||||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
|
||||||
fee = fee_rt * lev
|
|
||||||
out = []
|
|
||||||
last = -1
|
|
||||||
for e in ents:
|
|
||||||
i, d = e["i"], e["d"]
|
|
||||||
if i <= last or i + 1 >= n:
|
|
||||||
continue
|
|
||||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
|
||||||
continue
|
|
||||||
entry = c[i]
|
|
||||||
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
|
|
||||||
exit_p = c[min(i + mb, n - 1)]
|
|
||||||
j = min(i + mb, n - 1)
|
|
||||||
for k in range(1, mb + 1):
|
|
||||||
j = i + k
|
|
||||||
if j >= n:
|
|
||||||
exit_p = c[n - 1]
|
|
||||||
break
|
|
||||||
if mode == "orig":
|
|
||||||
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
|
||||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
|
||||||
if hs:
|
|
||||||
exit_p = sl0
|
|
||||||
break
|
|
||||||
if ht:
|
|
||||||
exit_p = tp
|
|
||||||
break
|
|
||||||
if k == mb:
|
|
||||||
exit_p = c[j]
|
|
||||||
else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
|
|
||||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
|
||||||
if ht:
|
|
||||||
exit_p = tp
|
|
||||||
break
|
|
||||||
aj = a[j] if np.isfinite(a[j]) else 0.0
|
|
||||||
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
|
||||||
(d == -1 and c[j] > sl0 + buffer * aj)
|
|
||||||
if confirm:
|
|
||||||
exit_p = c[j]
|
|
||||||
break
|
|
||||||
if k == mb:
|
|
||||||
exit_p = c[j]
|
|
||||||
ret = (exit_p - entry) / entry * d * lev - fee
|
|
||||||
out.append((i, j, ret))
|
|
||||||
last = j
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def fade_equity_variant(asset, fn, params, mode):
|
def fade_equity_variant(asset, fn, params, mode):
|
||||||
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
||||||
df = load_data(asset, "1h")
|
df = load_data(asset, "1h")
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
|
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
|
||||||
n = len(df)
|
return equity_from_trades(df, trades)
|
||||||
eq = np.full(n, INIT, dtype=float)
|
|
||||||
cap = INIT
|
|
||||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
|
||||||
cap = max(cap + cap * POS * ret, 10.0)
|
|
||||||
eq[j:] = cap
|
|
||||||
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
|
||||||
return _norm(s)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- pesi PORT06
|
|
||||||
def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
|
|
||||||
dr = port_returns(members, weights)
|
|
||||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -174,17 +91,9 @@ def main():
|
|||||||
for sid in fade_ids:
|
for sid in fade_ids:
|
||||||
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
||||||
|
|
||||||
ids = p.sleeve_ids
|
# pesi cap canonici (gli stessi che usa Portfolio.backtest) dentro port_metrics
|
||||||
# pesi cap canonici (gli stessi che usa Portfolio.backtest)
|
f_b, o_b = port_metrics(members_base, p)
|
||||||
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
|
f_e, o_e = port_metrics(members_e16, p)
|
||||||
w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
|
|
||||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
|
||||||
dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
|
|
||||||
w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
|
|
||||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
|
||||||
|
|
||||||
f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
|
|
||||||
f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
|
|
||||||
|
|
||||||
print("\n" + "=" * 96)
|
print("\n" + "=" * 96)
|
||||||
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
|
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
|
||||||
@@ -207,8 +116,6 @@ def main():
|
|||||||
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
|
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
|
||||||
for sid in fade_ids:
|
for sid in fade_ids:
|
||||||
ro = eq_orig[sid]; re = eq_e16[sid]
|
ro = eq_orig[sid]; re = eq_e16[sid]
|
||||||
def _dd(s):
|
|
||||||
pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
|
|
||||||
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
||||||
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
||||||
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
||||||
|
|||||||
@@ -29,116 +29,20 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from src.data.downloader import load_data
|
from src.data.downloader import load_data
|
||||||
from src.strategies.fade_base import hurst_skip_mask
|
from src.strategies.fade_base import hurst_skip_mask
|
||||||
from scripts.analysis.strategy_research import atr
|
from scripts.analysis.risk_management import strats_for
|
||||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
from scripts.analysis.combine_portfolio import OOS_DATE
|
||||||
from scripts.analysis.combine_portfolio import (
|
from scripts.analysis._port06_gate_common import (
|
||||||
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
|
||||||
)
|
)
|
||||||
from scripts.portfolios._defs import PORTFOLIOS
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
from src.portfolio import weighting as W
|
|
||||||
|
|
||||||
BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
|
|
||||||
HURST_MAX = 0.55 # loss-guard live
|
HURST_MAX = 0.55 # loss-guard live
|
||||||
TREND_MAX = 3.0
|
TREND_MAX = 3.0
|
||||||
EMA_LONG = 200
|
|
||||||
|
|
||||||
|
|
||||||
def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
|
|
||||||
buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
|
|
||||||
"""Engine di exit16_port06_impact.build_trades_variant + skip Hurst opzionale.
|
|
||||||
|
|
||||||
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
|
||||||
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
|
|
||||||
SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
|
|
||||||
trend_max : None = filtro OFF (live attuale); 3.0 = candidato.
|
|
||||||
hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard live).
|
|
||||||
"""
|
|
||||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
|
||||||
n = len(c)
|
|
||||||
a = atr(df, 14)
|
|
||||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
|
||||||
fee = fee_rt * lev
|
|
||||||
out = []
|
|
||||||
last = -1
|
|
||||||
for e in ents:
|
|
||||||
i, d = e["i"], e["d"]
|
|
||||||
if i <= last or i + 1 >= n:
|
|
||||||
continue
|
|
||||||
if hurst_mask is not None and hurst_mask[i]:
|
|
||||||
continue
|
|
||||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
|
||||||
continue
|
|
||||||
entry = c[i]
|
|
||||||
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
|
|
||||||
exit_p = c[min(i + mb, n - 1)]
|
|
||||||
j = min(i + mb, n - 1)
|
|
||||||
for k in range(1, mb + 1):
|
|
||||||
j = i + k
|
|
||||||
if j >= n:
|
|
||||||
exit_p = c[n - 1]
|
|
||||||
break
|
|
||||||
if mode == "orig":
|
|
||||||
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
|
||||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
|
||||||
if hs:
|
|
||||||
exit_p = sl0
|
|
||||||
break
|
|
||||||
if ht:
|
|
||||||
exit_p = tp
|
|
||||||
break
|
|
||||||
if k == mb:
|
|
||||||
exit_p = c[j]
|
|
||||||
else: # exit16
|
|
||||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
|
||||||
if ht:
|
|
||||||
exit_p = tp
|
|
||||||
break
|
|
||||||
aj = a[j] if np.isfinite(a[j]) else 0.0
|
|
||||||
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
|
||||||
(d == -1 and c[j] > sl0 + buffer * aj)
|
|
||||||
if confirm:
|
|
||||||
exit_p = c[j]
|
|
||||||
break
|
|
||||||
if k == mb:
|
|
||||||
exit_p = c[j]
|
|
||||||
ret = (exit_p - entry) / entry * d * lev - fee
|
|
||||||
out.append((i, j, ret))
|
|
||||||
last = j
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def equity_from_trades(df, trades):
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
n = len(df)
|
|
||||||
eq = np.full(n, INIT, dtype=float)
|
|
||||||
cap = INIT
|
|
||||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
|
||||||
cap = max(cap + cap * POS * ret, 10.0)
|
|
||||||
eq[j:] = cap
|
|
||||||
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
|
||||||
return _norm(s)
|
|
||||||
|
|
||||||
|
|
||||||
def port_metrics(members: dict[str, pd.Series], p):
|
|
||||||
ids = p.sleeve_ids
|
|
||||||
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
|
||||||
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
|
||||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
|
||||||
drp = port_returns({i: members[i] for i in ids}, w)
|
|
||||||
return metrics(drp), metrics(drp, lo=SPLIT)
|
|
||||||
|
|
||||||
|
|
||||||
def _dd(s):
|
|
||||||
pk = s.cummax()
|
|
||||||
return float(((pk - s) / pk).max() * 100)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
Reference in New Issue
Block a user