chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
View File
-94
View File
@@ -1,94 +0,0 @@
"""Portfolio: definizione (sleeve + schema pesi) con faccia di backtest.
La faccia live è in runner.py."""
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
from src.portfolio import weighting as W
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from scripts.analysis.combine_portfolio import port_returns, metrics, yearly_returns, SPLIT
@dataclass
class SleeveSpec:
kind: str
name: str
sid: str
asset: str | None = None
a: str | None = None
b: str | None = None
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = ""
@dataclass
class PortfolioResult:
code: str
weights: dict
full: dict
oos: dict
yearly: dict
risk: dict
@dataclass
class Portfolio:
code: str
label: str
sleeves: list[SleeveSpec]
weighting: str = "equal"
weights: dict | None = None
caps: dict | None = None
total_capital: float = 1000.0
leverage: float = 3.0
rebalance: str = "1D"
vol_lookback: int = 90
@property
def sleeve_ids(self) -> list[str]:
return [s.sid for s in self.sleeves]
@property
def clusters(self) -> dict[str, str]:
return {s.sid: (s.cluster or s.sid) for s in self.sleeves}
def weight_vector(self, returns_df: pd.DataFrame | None = None) -> dict[str, float]:
return W.weight_vector(
self.weighting, self.sleeve_ids, returns_df,
weights=self.weights, caps=self.caps,
clusters=self.clusters, lookback=self.vol_lookback,
)
def backtest(self) -> PortfolioResult:
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in self.sleeve_ids}
dr = sleeve_returns_df(self.sleeve_ids)
w = self.weight_vector(dr)
port_dr = port_returns(members, w)
full, oos = metrics(port_dr), metrics(port_dr, lo=SPLIT)
import numpy as np
we = np.ones(len(self.sleeve_ids)) / len(self.sleeve_ids)
cov = dr.cov().values
pv = float(we @ cov @ we)
rc = we * (cov @ we)
risk = {sid: float(rc[k] / pv * 100) if pv > 0 else 0.0
for k, sid in enumerate(self.sleeve_ids)}
return PortfolioResult(self.code, w, full, oos, yearly_returns(port_dr), risk)
def load_active_portfolio(config_path) -> "Portfolio":
"""Carica il portafoglio attivo da portfolios.yml applicando gli override."""
import yaml
from pathlib import Path
from scripts.portfolios._defs import PORTFOLIOS
cfg = yaml.safe_load(Path(config_path).read_text())
p = PORTFOLIOS[cfg["active"]]
ov = cfg.get("overrides", {})
for k in ("total_capital", "weighting", "caps", "leverage", "rebalance", "vol_lookback"):
if k in ov and ov[k] is not None:
setattr(p, k, ov[k])
return p
-92
View File
@@ -1,92 +0,0 @@
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
class PortfolioLedger:
def __init__(self, code: str, total_capital: float = 1000.0,
data_dir: Path = Path("data/portfolios")):
self.code = code
self.initial_capital = total_capital
self.total_capital = total_capital
self.work_dir = Path(data_dir) / code
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.equity_path = self.work_dir / "equity.jsonl"
self.events_path = self.work_dir / "events.jsonl"
self.equity = total_capital
self.peak = total_capital
self.max_dd = 0.0
self.weights: dict[str, float] = {}
self.alloc: dict[str, float] = {}
self.last_rebalance = ""
self._load()
def _load(self):
if not self.status_path.exists():
return
s = json.loads(self.status_path.read_text())
self.total_capital = s.get("total_capital", self.total_capital)
self.equity = s.get("equity", self.equity)
self.peak = s.get("peak", self.peak)
self.max_dd = s.get("max_dd", self.max_dd)
self.weights = s.get("weights", {})
self.alloc = s.get("alloc", {})
self.last_rebalance = s.get("last_rebalance", "")
def allocate(self, weights: dict[str, float],
reserved: dict[str, float] | None = None) -> dict[str, float]:
"""alloc per sid = peso × total_capital. `reserved` {sid: capitale trattenuto}
per i worker con posizione APERTA: quel capitale e' deployato, non
ridistribuibile -> i flat si dividono (total Σreserved) per peso
RINORMALIZZATO sui soli flat. Cosi' Σalloc == total_capital ed equity e'
CONSERVATA dal ribilancio. Senza reserved (default): comportamento storico
(alloc = peso×total per tutti), corretto solo se tutti i worker sono flat
(allocazione iniziale). Fix 2026-06-13: prima i flat si dividevano l'INTERO
total includendo il capitale degli in-position -> doppio conteggio, equity
gonfiata di Σ(capital_in_pos alloc_in_pos) (caso MR02_BTC 15m seedato e in
posizione al ribilancio: +4.77). Vedi docs/diary/2026-06-13-rebalance-conservation.md."""
self.weights = dict(weights)
reserved = reserved or {}
distributable = self.total_capital - sum(reserved.values())
flat = {sid: w for sid, w in weights.items() if sid not in reserved}
wsum = sum(flat.values())
self.alloc = {sid: round(cap, 6) for sid, cap in reserved.items()}
for sid, w in flat.items():
share = (w / wsum) if wsum > 0 else (1.0 / len(flat) if flat else 0.0)
self.alloc[sid] = round(distributable * share, 6)
self.last_rebalance = datetime.now(timezone.utc).isoformat()
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
"total_capital": self.total_capital,
"reserved": sorted(reserved)})
return self.alloc
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
self.equity = float(sum(sleeve_equity.values()))
if self.equity > self.peak:
self.peak = self.equity
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
self.max_dd = max(self.max_dd, dd)
self._append(self.equity_path, {
"ts": datetime.now(timezone.utc).isoformat(),
"equity": round(self.equity, 2), "dd": round(dd, 3),
"pnl_day": round(pnl_day, 2),
"pnl_total": round(self.equity - self.initial_capital, 2),
})
def save(self):
self.status_path.write_text(json.dumps({
"code": self.code, "total_capital": round(self.total_capital, 2),
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
"max_dd": round(self.max_dd, 3), "weights": self.weights,
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
"ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
@staticmethod
def _append(path: Path, row: dict):
with open(path, "a") as f:
f.write(json.dumps(row) + "\n")
-701
View File
@@ -1,701 +0,0 @@
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
Riusa i worker esistenti come esecutori e il data layer Cerbero v2.
Worker per tipo di sleeve:
single (fade/dip) -> StrategyWorker | ml (shape, SH01) -> StrategyWorker (WF interno)
pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker
rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker
Feed: il runner fetcha candele 1h da Cerbero v2 e le RESAMPLA a 4h/1d (come get_df nel
backtest) per i worker a cadenza piu' lenta. Il lookback per asset e' dimensionato sul
worker piu' esigente (TSM01 usa 252 giorni)."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from src.portfolio.base import SleeveSpec, Portfolio
from src.portfolio.ledger import PortfolioLedger
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker
from src.live.xsec_worker import CrossSectionalWorker
from src.live.grid_worker import GridWorker
from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
_STRAT_MODULE = {
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
"DIP01": "DIP01_dip_buy",
}
_MULTI_KINDS = ("basket", "rotation", "tsmom", "xsec")
DATA_DIR = Path("data/portfolio_paper")
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
_LOOKBACK_DAYS = {"5m": 7, "15m": 14, "30m": 21, "1h": 90, "4h": 220, "1d": 440}
# timeframe SUB-orari: si fetchano DIRETTI da Cerbero (non resamplabili dal 1h).
_SUBHOURLY = {"5m", "15m", "30m"}
# SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno
# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02.
_ML_LOOKBACK_DAYS = 365
def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float],
sleeve_ps: float | None = None) -> float:
"""position_size effettivo di uno sleeve. Precedenza: override PER-SLEEVE
(spec.params['position_size'], es. il 15m a 0.10) > override per-FAMIGLIA
(weighting.family_of: PAIRS/FADE/...) > globale."""
from src.portfolio.weighting import family_of
if sleeve_ps is not None:
return float(sleeve_ps)
return family_overrides.get(family_of(sid), global_ps)
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15,
executor=None, exec_instrument: str | None = None,
pairs_executor=None, exec_instruments: dict | None = None,
real_truth: bool = False):
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
(esecuzione reale a 2 gambe). real_truth: il ledger `capital` si aggiorna col PnL
dei FILL REALI (sim ridotto a diagnostica) — inerte senza executor."""
if spec.kind == "pairs":
return PairsWorker(
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
capital=alloc_capital, position_size=position_size, leverage=leverage,
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
executor=pairs_executor, exec_instruments=exec_instruments,
real_truth=real_truth,
)
if spec.kind == "basket":
pr = spec.params
return BasketTrendWorker(
universe=pr["universe"], tf=pr.get("tf", "4h"), capital=alloc_capital,
position_size=position_size, leverage=leverage, data_dir=data_dir,
)
if spec.kind == "rotation":
pr = spec.params
return RotationWorker(
universe=pr["universe"], top_k=pr.get("top_k", 3), gross=pr.get("gross", 0.45),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
if spec.kind == "tsmom":
pr = spec.params
return TsmomWorker(
universe=pr["universe"], horizons=tuple(pr.get("horizons", (63, 126, 252))),
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
if spec.kind == "xsec":
pr = spec.params
return CrossSectionalWorker(
universe=pr["universe"], tf=pr.get("tf", "1h"),
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
"disp_min": pr.get("disp_min"),
"tranches": pr.get("tranches", 1)},
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)
if spec.kind == "grid":
# Price Ladder (griglia) — SIM/PAPER (shadow stage 1): nessun ordine reale.
return GridWorker(
sid=spec.sid, asset=spec.asset, params=spec.params, capital=alloc_capital,
work_dir=Path(data_dir) / spec.sid, leverage=leverage,
position_size=position_size, fee_side=0.0005,
)
module = _STRAT_MODULE.get(spec.name)
if module is None:
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
strategy = load_strategy(module)
# SH01 (kind="ml") gira come StrategyWorker NORMALE: SH01_shape_ml.generate_signals fa il
# walk-forward (retraining) internamente ad ogni tick ed emette metadata.max_bars=H -> gli
# exit passano per StrategyWorker.tick (orizzonte H). NON usare il vecchio MLWorkerWrapper di
# multi_runner: quello usa SignalEngine (famiglia squeeze SCARTATA), apre senza metadata ed
# esce a hold_bars=3, ignorando del tutto SH01_shape_ml. Serve >=4000 barre 1h (train_min):
# garantite da _ML_LOOKBACK_DAYS.
return StrategyWorker(
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
executor=executor, exec_instrument=exec_instrument, real_truth=real_truth,
)
def _worker_equity(w) -> float:
inner = getattr(w, "worker", w) # smonta MLWorkerWrapper
return float(getattr(inner, "capital", 0.0))
def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[str, float]):
"""Ribilancio: total_capital = Σ equity sleeve; riallinea il capitale-base di ogni worker
a peso×total. I worker con posizione APERTA NON vengono ritoccati (la posizione mantiene
il suo notional, come da approssimazione dichiarata): il nuovo capitale-base si applica
alla prossima posizione, quando il worker è flat."""
ledger.total_capital = sum(_worker_equity(w) for w in workers.values())
# i worker in posizione TRATTENGONO il loro capitale (deployato): vanno passati
# come `reserved` ad allocate, cosi' i flat si dividono solo il RESTO e l'equity
# totale e' conservata (fix doppio conteggio 2026-06-13, vedi ledger.allocate).
reserved = {sid: _worker_equity(w) for sid, w in workers.items()
if getattr(getattr(w, "worker", w), "in_position", False)}
alloc = ledger.allocate(weights, reserved=reserved)
for sid, w in workers.items():
inner = getattr(w, "worker", w)
if getattr(inner, "in_position", False):
continue
inner.capital = alloc.get(sid, inner.capital)
ledger.save()
_OHLCV = ["timestamp", "open", "high", "low", "close", "volume"]
def _with_history(hist: pd.DataFrame | None, live: pd.DataFrame,
warned: set | None = None, asset: str = "") -> pd.DataFrame:
"""Bootstrap storia per SH01 (punto-10, 2026-06-07): parquet locale PRIMA del
feed live (dedup sul timestamp). La ri-validazione ha mostrato che l'edge SH01
richiede il training EXPANDING full-history: col solo lookback live (365g) la
LogReg e' over-confident e la strategia NON e' robusta. Se c'e' un gap fra
parquet e feed (parquet stantio oltre il lookback) si usa il SOLO feed con
WARN: meglio il regime corto dichiarato che una serie con un buco."""
if hist is None or live is None or live.empty:
return live
lo = int(live["timestamp"].iloc[0])
h = hist[hist["timestamp"] < lo]
if h.empty:
return live
if int(h["timestamp"].iloc[-1]) < lo - 2 * 3_600_000: # buco > 1 barra 1h
if warned is not None and asset not in warned:
warned.add(asset)
print(f"[runner] WARN: gap fra parquet e feed live per {asset} "
f"(parquet stantio? rilanciare download_all) — SH01 senza bootstrap")
return live
return pd.concat([h[_OHLCV], live[_OHLCV]], ignore_index=True)
def _resample(df: pd.DataFrame, tf: str) -> pd.DataFrame:
"""Resampla candele 1h -> 4h/1d mantenendo timestamp ms reale (come get_df del backtest)."""
if tf == "1h":
return df
rule = {"4h": "4h", "1d": "1D"}[tf]
d = df.copy()
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.set_index("dt")
agg = d.resample(rule).agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"}).dropna()
epoch = pd.Timestamp("1970-01-01", tz="UTC")
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return agg.reset_index(drop=True)
def _spec_assets_tf(spec: SleeveSpec):
"""(lista asset, tf) coinvolti da uno sleeve."""
if spec.kind == "pairs":
return [spec.a, spec.b], spec.tf
if spec.kind in _MULTI_KINDS:
return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h")
if spec.kind == "grid":
return [spec.asset], spec.params.get("tf", spec.tf)
return [spec.asset], spec.tf
_STALE_BARS = 2 # barre 1h COMPLETE consecutive flat (O=H=L=C) -> feed fermo
def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
"""Osservabilita' (2026-06-05): alert Telegram quando il feed e' flat/fermo da
>= _STALE_BARS barre 1h complete (i worker sono ciechi: il prossimo prezzo reale
puo' gappare attraverso TP/SL, come ETH flat 13:00-14:50 -> gap 1655->1600) e al
risveglio (con il gap % del primo prezzo reale). Una notifica per episodio.
NB: SOLO osservabilita' — saltare gli ingressi post-flat PEGGIORA l'edge
(testato: la candela-gap e' l'overshoot che la fade fada con profitto)."""
from src.live.bars import last_settled_idx
from src.live.telegram_notifier import notify_event
if len(df) < _STALE_BARS + 2:
return
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
# ultima barra COMPLETA (detection condivisa src.live.bars; prima hardcodava 1h)
k = last_settled_idx(df["timestamp"].values)
i = len(c) + k
if i < 1:
return
flat = (o == h) & (h == l) & (l == c)
n_flat = 0
while i - n_flat >= 0 and flat[i - n_flat]:
n_flat += 1
if n_flat >= _STALE_BARS and asset not in alerted:
alerted.add(asset)
notify_event("STALE_FEED", {"asset": asset, "flat_bars_1h": n_flat,
"ultimo_prezzo": float(c[i])})
elif n_flat == 0 and asset in alerted:
alerted.discard(asset)
gap = (c[i] / c[i - 1] - 1) * 100 if c[i - 1] else 0.0
notify_event("STALE_FEED", {"asset": asset, "status": "RIPRESO",
"gap_pct": round(gap, 2), "prezzo": float(c[i])})
# --- Gate feed CONGELATO (2026-06-15) -----------------------------------------
# Distinto da STALE_FEED (osservabilita') e FEED_BOOK_GAP (divergenza esecuzione):
# qui si AGISCE. Quando il feed di DECISIONE 1h di un asset e' CONGELATO (guasto
# testnet: ETH-PERPETUAL inverse fermo a 1661.95 per 36h+, prezzo MAI cambiato) gli
# sleeve CONCENTRATI che ne dipendono (single/ml/pairs) decidono entry/exit su un
# prezzo morto -> segnali spuri (z-score pairs estremi -3/-5, SH01 short a prezzo
# fermo) e perdite REALI (i fill avvengono al book vero ~1717, l'uscita sim al prezzo
# congelato: SH01_ETH ha realizzato -2.83 reali vs -0.09 sim su un solo close). Si
# SALTA il tick (entry E exit) finche' il feed non si sblocca: come un outage (i worker
# non valutano gli exit, protezione = disaster-SL on-book), auto-guarente.
#
# DISTINGUERE guasto da ILLIQUIDITA': SOL/LTC/ADA stampano molte barre flat (O=H=L=C,
# 50-95%) ma il prezzo SI muove ogni poche ore (run di close identiche CORTE, ~2-5). Il
# guasto e' il prezzo che non cambia MAI: run di close INVARIATE >= soglia (il freeze
# reale dura decine di barre). Un detector flat-bar ingenuo gaterebbe gli alt
# illiquidi-ma-VIVI -> si conta la run di close invariate, NON le barre flat.
#
# NB POST-FLAT: si gatea DURANTE il freeze (ultima barra completa ferma). La barra di
# RIPRESA e' non-flat -> la run si azzera -> il tick riprende SU di essa. NON e'
# l'entry-guard post-flat (BOCCIATA: la candela-gap e' l'overshoot che la fade fada con
# profitto, CLAUDE.md / 2026-06-05): quello salta la barra di ripresa, questo no.
#
# SOGLIA (misurata sul feed reale 2026-06-15): le due popolazioni sono ben separate ->
# MORTO ETH run 64 (1 val distinto/48h), BNB 64, DOGE 42 (feed pinnato a un valore)
# ILLIQUIDO LTC run 10, ADA 11, XRP 12, SOL 1 (5-31 val distinti/48h: SI muovono)
# A 24 (un giorno intero di prezzo immobile) il guasto multi-giorno e' preso con ampio
# margine senza gateare gli alt illiquidi-ma-VIVI (importante per PR_BTCLTC: BTC vivo +
# LTC solo illiquido NON deve sospendere il pair). ETH (run 64) gatea comunque subito.
_FREEZE_GATE_BARS = 24 # run di close INVARIATE sull'ultima barra completa = freeze
def _frozen_assets(raw1h: dict[str, pd.DataFrame], threshold: int) -> set[str]:
"""Asset col feed di decisione 1h CONGELATO: ultima barra completa flat (O=H=L=C)
E close invariata da >= `threshold` barre complete consecutive. 0 = gate disattivo."""
from src.live.bars import last_settled_idx
frozen: set[str] = set()
if threshold <= 0:
return frozen
for asset, df in raw1h.items():
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
i = len(c) + last_settled_idx(df["timestamp"].values)
if i < 1 or not (o[i] == h[i] == l[i] == c[i]):
continue # ultima barra completa non flat -> vivo
run = 1
while i - run >= 0 and c[i - run] == c[i]:
run += 1
if run >= threshold:
frozen.add(asset)
return frozen
def _feed_gated_sids(live_specs, frozen: set[str]) -> set[str]:
"""sid degli sleeve CONCENTRATI (single/ml/pairs) che dipendono da un asset col feed
congelato. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER) NON sono gateati:
diversificati su 8 asset, un singolo feed fermo non li compromette."""
if not frozen:
return set()
return {s.sid for s in live_specs
if s.kind in ("single", "ml", "pairs")
and any(a in frozen for a in _spec_assets_tf(s)[0])}
_GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile
def _check_feed_book_gap(client, raw1h, instruments, threshold_bps, alerted):
"""Osservabilita' (2026-06-12): il feed candele e il book dove fillano gli ordini
REALI possono divergere — caso MR02_BTC: TP resting fillato a 60481 nella notte
col feed mai sceso sotto 63285 (-443 bps, scoperto solo al close sim); i wick
TP_PHANTOM sono il caso opposto (feed stampa, book non scambia). Confronta il
close della candela in corso col MARK dello strumento d'ESECUZIONE (USDC):
oltre soglia -> alert FEED_BOOK_GAP, una notifica per episodio, recovery con
isteresi a soglia/2. Le decisioni restano sul feed (il sim e' la verita' che
guida): questo dice solo QUANDO i fill reali possono divergere dal sim."""
from src.live.telegram_notifier import notify_event
want = {a: inst for a, inst in instruments.items() if a in raw1h}
if not want:
return
try:
out = client.get_ticker_batch(list(want.values()))
marks = {t.get("instrument_name"): (t.get("mark_price") or t.get("last_price"))
for t in out.get("tickers", [])}
except Exception:
return # fail-open: solo osservabilita'
for asset, inst in want.items():
mark = marks.get(inst)
feed = float(raw1h[asset]["close"].iloc[-1])
if not mark or not feed:
continue
gap_bps = abs(feed / float(mark) - 1) * 10_000
if gap_bps >= threshold_bps and asset not in alerted:
alerted.add(asset)
print(f"[runner] FEED_BOOK_GAP {asset}: feed {feed} vs mark {mark} "
f"({gap_bps:.0f} bps)")
notify_event("FEED_BOOK_GAP", {
"asset": asset, "feed_close": feed, "mark_book": float(mark),
"gap_bps": round(gap_bps, 1),
"nota": "feed candele != book d'esecuzione: i fill reali possono "
"divergere dal sim (TP fantasma / fill non visti dal feed)"})
elif gap_bps < threshold_bps / 2 and asset in alerted:
alerted.discard(asset)
notify_event("FEED_BOOK_GAP", {"asset": asset, "status": "RIENTRATO",
"gap_bps": round(gap_bps, 1)})
def run(config_path: str = "portfolios.yml"):
"""Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample;
ribilancio a cambio giornata UTC."""
import time
from datetime import datetime, timezone, timedelta
import yaml
from src.portfolio.base import load_active_portfolio
from src.portfolio.sleeves import sleeve_returns_df
from src.portfolio import weighting as W
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
p: Portfolio = load_active_portfolio(config_path)
_ov = (yaml.safe_load(Path(config_path).read_text()) or {}).get("overrides", {})
poll = int(_ov.get("poll_seconds", 60))
# Frazione di capitale-sleeve impegnata per posizione (default 0.15 = canonico backtest).
# Con leva 2x: notional = capital * position_size * 2. A 0.5 ogni sleeve in posizione
# impegna il 100% della sua fetta (max impiego senza debito di margine); DD scala ~lineare.
position_size = float(_ov.get("position_size", 0.15))
# Override PER-FAMIGLIA (improvement-sweep punto 8): la chiave e' la famiglia di
# weighting.family_of (PAIRS/FADE/HONEST/SHAPE/TSM). Nato per i pairs: tutta la
# validazione PR01 e' a pos 0.15 e la famiglia e' SENZA stop -> il pos globale 0.5
# la faceva girare a ~2.2x l'esposizione validata. Gate: pairspos_port06_impact.py.
ps_family = {str(k).upper(): float(v)
for k, v in (_ov.get("position_size_family") or {}).items()}
def _supported(s):
return s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE
supported = [s for s in p.sleeves if _supported(s)]
skipped = [s.sid for s in p.sleeves if not _supported(s)]
if skipped:
print(f"[runner] sleeve saltati nel live (worker non disponibili): {skipped}")
# SLEEVE "PAPER" (solo statistica, 2026-06-08): NON entrano nel pool/pesi/ledger del
# portafoglio — i €total_capital si dividono SOLO tra gli sleeve reali. I paper girano
# con un capitale nozionale fisso (la fetta equal che avrebbero avuto) per raccogliere
# statistica in vista di future implementazioni reali. Default: TR01/ROT02/TSM01
# (multi-asset, esecuzione reale bloccata dal capitale).
paper_codes = {str(c).upper() for c in (_ov.get("paper_sleeves") or [])}
live_specs = [s for s in supported if s.name.upper() not in paper_codes]
paper_specs = [s for s in supported if s.name.upper() in paper_codes]
# PAPER_EXTRA: sleeve paper definiti SOLO in config (NON in p.sleeves) -> NON entrano
# nel backtest canonico/regression-lock (all_sleeve_equities non sa costruirli). Nato
# per il Price Ladder (kind=grid, shadow stage 1 sim). Parsing DIFENSIVO: un errore qui
# non deve crashare il runner mainnet.
for _ex in (_ov.get("paper_extra") or []):
try:
paper_specs.append(SleeveSpec(
kind=str(_ex["kind"]), name=str(_ex.get("name", _ex["sid"])),
sid=str(_ex["sid"]), asset=_ex.get("asset"),
tf=str(_ex.get("tf", "1h")), params=dict(_ex.get("params", {})),
cluster=str(_ex.get("cluster", "")),
))
except Exception as e:
print(f"[runner] WARN paper_extra ignorato ({_ex}): {e}")
if paper_specs:
print(f"[runner] sleeve PAPER (solo statistica, fuori dal pool): "
f"{[s.sid for s in paper_specs]}")
live_ids = [s.sid for s in live_specs]
clusters = {s.sid: (s.cluster or s.sid) for s in live_specs}
paper_notional = p.total_capital / max(len(supported), 1) # fetta equal nozionale
ledger = PortfolioLedger(p.code, total_capital=p.total_capital)
client = CerberoClient()
# --- Esecuzione REALE (shadow) su Deribit testnet, solo sui fade abilitati ---
# overrides.execution: {enabled, sleeves:[MR01,...], instruments:{BTC:..,ETH:..}}
_exec_cfg = _ov.get("execution", {}) or {}
exec_enabled = bool(_exec_cfg.get("enabled"))
exec_sleeves = set(_exec_cfg.get("sleeves", []))
exec_instr = _exec_cfg.get("instruments", {}) or {}
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
# REAL-TRUTH (2026-06-10): il capitale degli sleeve eseguiti si aggiorna col PnL
# dei fill reali (sim = diagnostica). Default False: va acceso esplicitamente in yml.
real_truth = bool(_exec_cfg.get("real_truth", False))
executor = None
pairs_executor = None
if exec_enabled:
from src.live.execution import ExecutionClient
executor = ExecutionClient(client=client)
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct} "
f"real_truth={real_truth}")
if pairs_exec_enabled:
from src.live.execution import PairsExecutionClient
pairs_executor = PairsExecutionClient(leg=executor)
print(f"[runner] ESECUZIONE REALE PAIRS (2 gambe) attiva — strumenti={exec_instr}")
def _exec_for(s):
"""(executor, exec_instrument) per uno sleeve single-leg ABILITATO. Kind:
'single' (fade/DIP01) e 'ml' (SH01). SH01 non ha TP/SL -> _place_real_tp
ritorna subito e _real_close chiude tutto a market reduce-only (orizzonte):
infrastruttura gia' presente. Il disaster-bracket on-book resta l'unica
protezione di coda di SH01 durante un outage (esce a H=12 ben prima del -30%)."""
if not exec_enabled or s.kind not in ("single", "ml") or s.name not in exec_sleeves:
return None, None
return executor, exec_instr.get(s.asset)
def _pairs_exec_for(s):
"""(pairs_executor, {asset: instrument}) per uno sleeve pairs, se abilitato."""
if not pairs_exec_enabled or s.kind != "pairs":
return None, None
return pairs_executor, exec_instr
dr = sleeve_returns_df(live_ids)
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
alloc = ledger.allocate(weights)
workers = {}
for s in live_specs:
ex, inst = _exec_for(s)
pex, pinst = _pairs_exec_for(s)
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")),
executor=ex, exec_instrument=inst,
pairs_executor=pex, exec_instruments=pinst,
real_truth=real_truth)
if ps_family:
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
# worker PAPER (solo statistica): capitale nozionale fisso, NESSUNA esecuzione reale,
# NON nel ledger del portafoglio. Salvano in data/portfolio_paper_stats/.
paper_dir = DATA_DIR.parent / "portfolio_paper_stats"
paper_workers = {s.sid: build_worker_for(s, paper_notional, p.leverage,
data_dir=paper_dir,
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")))
for s in paper_specs}
# bootstrap storia full per gli sleeve ML (SH01): parquet locale + feed live.
# L'edge SH01 richiede train expanding full-history (sh01_trainwindow_validate);
# il path live fitta solo l'ultimo blocco (last_block_only nei params SHAPE).
ml_hist: dict[str, pd.DataFrame] = {}
ml_gap_warned: set[str] = set()
for a in sorted({s.asset for s in live_specs if s.kind == "ml"}):
try:
from src.data.downloader import load_data
ml_hist[a] = load_data(a, "1h")
last = pd.to_datetime(ml_hist[a]["timestamp"].iloc[-1], unit="ms", utc=True)
print(f"[runner] bootstrap storia SH01 {a}: {len(ml_hist[a])} barre parquet "
f"(fino a {last:%Y-%m-%d %H:%M})")
except Exception as e:
print(f"[runner] WARN bootstrap storia {a} fallito: {e} — SH01 col solo feed")
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {}
for s in live_specs + paper_specs: # live + PAPER (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
days = _LOOKBACK_DAYS.get(tf, 90)
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
days = max(days, _ML_LOOKBACK_DAYS)
for a in assets:
asset_days[a] = max(asset_days.get(a, 0), days)
# timeframe SUB-orari (es. pairs 15m, flat-skip): non resamplabili dal 1h ->
# fetch DIRETTO da Cerbero per (asset, tf). Inerte se nessuno sleeve e' sub-orario.
subhourly_needs: dict[tuple[str, str], int] = {}
for s in live_specs + paper_specs: # live + paper (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
if tf in _SUBHOURLY:
for a in assets:
subhourly_needs[(a, tf)] = max(subhourly_needs.get((a, tf), 0),
_LOOKBACK_DAYS.get(tf, 14))
if subhourly_needs:
print(f"[runner] timeframe sub-orari (fetch diretto Cerbero): {sorted(subhourly_needs)}")
inst_map = dict(INSTRUMENT_MAP)
last_day = ""
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
# guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off)
gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT))
gap_alerted: set[str] = set()
# gate feed CONGELATO (2026-06-15): salta gli sleeve concentrati su un asset col feed
# fermo. Soglia (barre 1h di close invariata) in overrides.feed_freeze_gate_bars (0 = off).
freeze_gate_bars = int(_ov.get("feed_freeze_gate_bars", _FREEZE_GATE_BARS))
spec_by_id = {s.sid: s for s in live_specs}
frozen_gated: set[str] = set() # sleeve gateati ora (dedup alert + recovery)
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
# "Degradato" include il caso HTTP-200-con-candles-vuote (code review 2026-06-07):
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
_OUTAGE_POLLS = 5
fail_streak = 0
worker_err_streak: dict[str, int] = {} # errori consecutivi per worker (isolamento tick)
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
volta per episodio; RIPRESO al primo poll pulito). Ritorna il nuovo streak."""
from src.live.telegram_notifier import notify_event
if failed:
streak += 1
if streak == _OUTAGE_POLLS:
real_open = sorted(sid for sid, wk in workers.items()
if getattr(wk, "real_in_position", False))
notify_event("FEED_OUTAGE", {
"poll_falliti": streak,
"minuti": round(streak * poll / 60),
"dettaglio": detail,
"posizioni_reali_aperte": ", ".join(real_open) or "nessuna",
"nota": "exit NON valutati durante l'outage; "
"protezione = disaster-SL on-book sui fade reali"})
return streak
if streak >= _OUTAGE_POLLS:
notify_event("FEED_OUTAGE", {"status": "RIPRESO",
"poll_falliti": streak,
"minuti": round(streak * poll / 60)})
return 0
while True:
try:
# fetch 1h per asset al lookback massimo richiesto
raw1h: dict[str, pd.DataFrame] = {}
end = datetime.now(timezone.utc)
# SOLO testnet (via Cerbero): il paper DEVE usare lo stesso venue dove gli ordini
# verrebbero eseguiti (testnet). Mai sostituire con dati mainnet -> divergerebbe dal
# comportamento reale (prezzi/liquidità testnet != mainnet). Durante un outage testnet
# il runner si mette in pausa (corretto: senza il venue non si potrebbe eseguire).
for asset, days in asset_days.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "1h")
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
_check_stale_feed(asset, raw1h[asset], stale_alerted)
if exec_enabled and gap_bps > 0:
_check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted)
# gate feed CONGELATO: sleeve concentrati su un asset col feed fermo -> tick saltato
frozen = _frozen_assets(raw1h, freeze_gate_bars)
now_gated = _feed_gated_sids(live_specs, frozen)
if now_gated != frozen_gated:
from src.live.telegram_notifier import notify_event
for sid in sorted(now_gated - frozen_gated):
fa = sorted(set(_spec_assets_tf(spec_by_id[sid])[0]) & frozen)
print(f"[runner] FEED_FROZEN_GATE {sid}: asset congelati {fa} -> tick sospeso")
notify_event("FEED_FROZEN_GATE", {
"sleeve": sid, "asset_congelati": ", ".join(fa), "stato": "GATED",
"nota": "feed di decisione fermo: entry+exit sospesi finche' non si "
"sblocca (disaster-SL on-book protegge le posizioni reali)"})
for sid in sorted(frozen_gated - now_gated):
print(f"[runner] FEED_FROZEN_GATE {sid}: feed ripreso -> tick riattivato")
notify_event("FEED_FROZEN_GATE", {"sleeve": sid, "stato": "RIPRESO"})
frozen_gated = now_gated
# fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), tf)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
raw_sub[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
def _series_for(a, tf):
"""Serie OHLC per (asset, tf): diretta se sub-oraria, altrimenti resample dal 1h."""
if tf in _SUBHOURLY:
return raw_sub.get((a, tf))
return _resample(raw1h[a], tf) if a in raw1h else None
# tick di ogni worker col suo timeframe
def _tick(s, w):
assets, tf = _spec_assets_tf(s)
res = {a: _series_for(a, tf) for a in assets}
if any(res[a] is None or len(res[a]) == 0 for a in assets):
return
if s.kind == "pairs":
w.tick(res[s.a], res[s.b])
elif s.kind in _MULTI_KINDS:
w.tick(res)
elif s.kind == "ml":
# SH01: storia full (parquet bootstrap + feed) -> il walk-forward
# interno fitta solo l'ultimo blocco (last_block_only nei params).
w.tick(_with_history(ml_hist.get(s.asset), res[s.asset],
ml_gap_warned, s.asset))
elif s.kind == "grid":
# Price Ladder SIM/PAPER: ricomputa la griglia sul feed live (BTC 1h).
w.tick(res[s.asset])
else:
# single (fade/dip): StrategyWorker su feed live.
w.tick(res[s.asset])
# isolamento per-worker (audit 2026-06-11): un'eccezione in un tick NON
# deve saltare gli altri worker (= exit non valutati per tutti) ne'
# l'update dell'equity. Streak per-worker -> alert dopo 5 fail di fila.
def _tick_safe(s, w):
try:
_tick(s, w)
n_prev = worker_err_streak.pop(s.sid, 0)
if n_prev >= 5:
# recovery dichiarato (code-review: alert one-shot senza
# ripresa = silenzio indistinguibile dal guasto)
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK",
{"worker": s.sid, "status": "RIPRESO",
"dopo_streak": n_prev})
except Exception as e:
n = worker_err_streak.get(s.sid, 0) + 1
worker_err_streak[s.sid] = n
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)")
# alert a 5 e poi ogni 50 poll (~1h): un worker rotto per
# giorni non deve sparire dopo il primo Telegram. Include
# se ha una posizione REALE aperta (exit non valutati!)
if n == 5 or n % 50 == 0:
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK", {
"worker": s.sid, "streak": n, "errore": str(e)[:200],
"real_in_position": bool(getattr(w, "real_in_position", False)),
"in_position": bool(getattr(w, "in_position", False)),
"nota": "exit NON valutati per questo worker"})
for s in live_specs:
if s.sid in now_gated:
continue # feed di decisione congelato -> entry+exit sospesi
_tick_safe(s, workers[s.sid])
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
for s in paper_specs:
_tick_safe(s, paper_workers[s.sid])
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if today != last_day and last_day:
dr = sleeve_returns_df(live_ids)
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
rebalance_allocations(ledger, workers, weights)
last_day = today
ledger.save()
# feed degradato senza eccezione: asset richiesti ma senza candele
missing = sorted(a for a in asset_days if a not in raw1h)
if missing:
print(f"[runner] feed incompleto: mancano {missing} (streak {fail_streak + 1})")
fail_streak = _outage_tick(bool(missing), fail_streak,
detail=f"feed senza candele per: {', '.join(missing)}")
except KeyboardInterrupt:
ledger.save()
print("shutdown")
break
except Exception as e:
print(f"[runner] errore: {e} (streak {fail_streak + 1})")
fail_streak = _outage_tick(True, fail_streak, detail=f"eccezione: {e}")
time.sleep(poll)
if __name__ == "__main__":
run()
-26
View File
@@ -1,26 +0,0 @@
"""Unico builder delle equity GIORNALIERE per sleeve (fonte di verità del backtest).
Delega a scripts/analysis/report_families.build_everything (che a sua volta usa
combine_portfolio + pairs_research + tsmom_research + shape_ml_validate), così le
metriche del Portfolio coincidono per costruzione con report_families."""
from __future__ import annotations
import pandas as pd
_CACHE: dict[str, pd.Series] | None = None
def all_sleeve_equities() -> dict[str, pd.Series]:
"""{sleeve_id: equity giornaliera normalizzata su IDX comune}. Cache di processo."""
global _CACHE
if _CACHE is None:
from scripts.analysis.report_families import build_everything
S, pairs, tsm, shape = build_everything()
_CACHE = {**S, **pairs, **tsm, **shape}
return _CACHE
def sleeve_returns_df(ids: list[str]) -> pd.DataFrame:
"""Rendimenti giornalieri allineati per gli sleeve richiesti."""
eq = all_sleeve_equities()
return pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
-96
View File
@@ -1,96 +0,0 @@
"""Schemi di peso per i portafogli. Ogni funzione ritorna {sleeve_id: peso} con somma 1."""
from __future__ import annotations
import numpy as np
import pandas as pd
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE"), ("XS", "XSEC")]
def family_of(sleeve_id: str) -> str:
for pre, fam in _PREFIX:
if sleeve_id.startswith(pre):
return fam
return "HONEST"
def _normalize(w: dict[str, float]) -> dict[str, float]:
tot = sum(w.values())
return {k: (v / tot if tot > 0 else 0.0) for k, v in w.items()}
def equal(ids: list[str]) -> dict[str, float]:
n = len(ids)
return {i: 1.0 / n for i in ids} if n else {}
def manual(ids: list[str], weights: dict[str, float]) -> dict[str, float]:
return _normalize({i: float(weights.get(i, 0.0)) for i in ids})
def cap(ids: list[str], caps: dict[str, float]) -> dict[str, float]:
"""Equal-weight con tetto al peso AGGREGATO di una famiglia; l'eccesso ridistribuito
pro-quota alle famiglie non cappate (iterativo finché tutti i cap sono rispettati)."""
w = equal(ids)
fam = {i: family_of(i) for i in ids}
for _ in range(10):
over = {}
for f, lim in caps.items():
members = [i for i in ids if fam[i] == f]
cur = sum(w[i] for i in members)
if cur > lim + 1e-12 and members:
over[f] = (members, lim, cur)
if not over:
break
free_ids = [i for i in ids if fam[i] not in caps]
freed = 0.0
for f, (members, lim, cur) in over.items():
scale = lim / cur
for i in members:
freed += w[i] * (1 - scale)
w[i] *= scale
if free_ids and freed > 0:
add = freed / len(free_ids)
for i in free_ids:
w[i] += add
else:
break
return _normalize(w)
def inverse_vol(ids: list[str], returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
sub = returns_df[ids].iloc[-lookback:]
vol = sub.std()
inv = {i: (1.0 / vol[i] if vol[i] and vol[i] > 0 else 0.0) for i in ids}
return _normalize(inv)
def cluster_rp(ids: list[str], clusters: dict[str, str],
returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
"""Equal fra i cluster naturali, poi inverse-vol dentro ogni cluster."""
groups: dict[str, list[str]] = {}
for i in ids:
groups.setdefault(clusters.get(i, i), []).append(i)
per = 1.0 / len(groups) if groups else 0.0
w: dict[str, float] = {}
for members in groups.values():
iv = inverse_vol(members, returns_df, lookback)
for i in members:
w[i] = per * iv[i]
return _normalize(w)
def weight_vector(scheme: str, ids: list[str], returns_df: pd.DataFrame | None = None,
*, weights: dict | None = None, caps: dict | None = None,
clusters: dict | None = None, lookback: int = 90) -> dict[str, float]:
if scheme == "equal":
return equal(ids)
if scheme == "manual":
return manual(ids, weights or {})
if scheme == "cap":
return cap(ids, caps or {})
if scheme == "inverse_vol":
return inverse_vol(ids, returns_df, lookback)
if scheme == "cluster_rp":
return cluster_rp(ids, clusters or {}, returns_df, lookback)
raise ValueError(f"schema peso sconosciuto: {scheme}")