4c184d5cbc
Audit "stato ordini": il feed ETH-PERPETUAL congelato a 1661.95 (36h+) generava perdite REALI (SH01_ETH -2.83 reali vs -0.09 sim su un close + riapertura; 4 pairs con gamba ETH entrati su z-score spuri -3/-5/+5.6 = artefatto del log-ratio con ETH pinnato mentre gli alt si muovono). _frozen_assets + _feed_gated_sids: quando il feed di decisione 1h di un asset e' congelato, gli sleeve concentrati (single/ml/pairs) che ne dipendono saltano il tick (entry+exit) finche' non si sblocca (come outage; disaster-SL on-book = coda). Auto-guarente: rilascio alla prima barra COMPLETA non-flat (NON l'entry-guard post-flat bocciata). Detector guasto-vs-illiquido: conta la run di close INVARIATE (ETH/BNB/DOGE run 40-64, 1-4 val/48h = morti; SOL/LTC/ADA run <=12, 5-31 val = vivi). Soglia feed_freeze_gate_bars=24 -> gatea le 9 gambe ETH esatte, PR_BTCLTC e i multi-asset restano attivi. Alert Telegram FEED_FROZEN_GATE GATED/RIPRESO. Test test_freeze_gate.py (6, detector+scope+rilascio). Suite portfolio 140/140. Cerotto testnet: il fix vero e' mainnet. Diario 2026-06-15-frozen-feed-gate.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
675 lines
36 KiB
Python
675 lines
36 KiB
Python
"""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.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,
|
||
)
|
||
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")
|
||
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]
|
||
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 supported: # live + PAPER (anche XS01/TR01/ROT02/TSM01)
|
||
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 supported: # live + paper
|
||
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))
|
||
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()
|