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:
@@ -0,0 +1,701 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user