feat(pairs): attiva ETH/BTC 15m flat-skip in PORT06 (BLEND, mezza size)

Origine: gioco "Blind Traders" (100 agenti ciechi su BTC/ETH anonimizzati) ->
vincitore = spread ETH/BTC reversion a 15m. Testato sul serio col gate PORT06:
non duplicato (corr 1h vs 15m = 0.37), robusto (16/16 celle Sharpe>1), edge NON
artefatto delle candele flat ETH 15m (filtrandole resta l'83% dello Sharpe).

Percorso live costruito e validato:
- pairs_research.pairs_sim_flat: engine generalizzato con exit LIVE-REALIZABLE
  (arma exit_ready, esce alla 1a barra pulita); regression-lock a pairs_sim.
- PairsWorker: flat_skip + exit_ready + rilevamento flat da OHLC (1h byte-exact).
- runner: fetch diretto dei timeframe sub-orari + override position_size per-sleeve.
- validate_worker_pairs: replay worker == backtest a 15m (8452 vs 8453 trade).
- _defs/build_everything: sleeve PR_ETHBTC_15M (mezza size, pos 0.10) -> PORT06
  FULL 6.43->7.20, OOS 8.58->9.66, DD giu'. Rischio bilanciato col 1h.
- smoke live: Cerbero serve candele 15m fresche; worker ticca.

Diari docs/diary/2026-06-09-*. Caveat slippage: mezza size = blend-tilt prudente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-09 11:48:15 +00:00
parent 5d45f4ef6e
commit d25d897fd1
20 changed files with 1727 additions and 60 deletions
+34 -11
View File
@@ -51,6 +51,11 @@ class PairsWorker:
self.z_exit = float(p.get("z_exit", 0.75))
self.max_bars = int(p.get("max_bars", 72))
self.jump_max = float(p.get("jump_max", 0.08))
# flat-skip (timeframe sub-orari, es. 15m): non entrare/uscire su candele flat
# (O=H=L=C, prezzo stale/liquidita' zero -> fill non eseguibile). LIVE-REALIZABLE:
# l'uscita arma exit_ready e si esegue alla prima barra PULITA. Parita' col backtest
# pairs_research.pairs_sim_flat(flat_skip=True). Default off = comportamento 1h storico.
self.flat_skip = bool(p.get("flat_skip", False))
self.initial_capital = capital
self.position_size = position_size
@@ -71,6 +76,7 @@ class PairsWorker:
self.entry_z = 0.0
self.entry_time = ""
self.bars_held = 0
self.exit_ready = False # flat-skip: condizione di uscita armata, attende barra pulita
self.total_trades = 0
self.total_wins = 0
self.last_bar_ts = 0
@@ -117,6 +123,7 @@ class PairsWorker:
self.entry_z = s.get("entry_z", 0.0)
self.entry_time = s.get("entry_time", "")
self.bars_held = s.get("bars_held", 0)
self.exit_ready = s.get("exit_ready", False)
self.total_trades = s.get("total_trades", 0)
self.total_wins = s.get("total_wins", 0)
self.last_bar_ts = s.get("last_bar_ts", 0)
@@ -145,7 +152,8 @@ class PairsWorker:
"capital": round(self.capital, 2), "in_position": self.in_position,
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
"bars_held": self.bars_held, "total_trades": self.total_trades,
"bars_held": self.bars_held, "exit_ready": self.exit_ready,
"total_trades": self.total_trades,
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
"real_capital": round(self.real_capital, 4), "real_in_position": self.real_in_position,
@@ -185,6 +193,7 @@ class PairsWorker:
self.entry_a, self.entry_b, self.entry_z = ca, cb, z
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
self.exit_ready = False
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
"long_leg": self.asset_a if d == 1 else self.asset_b,
"short_leg": self.asset_b if d == 1 else self.asset_a,
@@ -287,9 +296,13 @@ class PairsWorker:
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
if df_a is None or df_b is None or df_a.empty or df_b.empty:
return
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner"
).sort_values("timestamp").reset_index(drop=True)
# merge OHLC quando disponibile (serve a rilevare le candele flat per il flat-skip);
# se le colonne OHLC mancano, flat resta False -> comportamento close-only invariato.
ohlc = ["open", "high", "low", "close"]
keep_a = ["timestamp"] + [c for c in ohlc if c in df_a.columns]
keep_b = ["timestamp"] + [c for c in ohlc if c in df_b.columns]
m = df_a[keep_a].merge(df_b[keep_b], on="timestamp", how="inner",
suffixes=("_a", "_b")).sort_values("timestamp").reset_index(drop=True)
# Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di
# barra COMPLETA, come il backtest (pairs_research: close settled) —
# lezione EXIT-16. Detection condivisa: src.live.bars.
@@ -298,7 +311,7 @@ class PairsWorker:
m = m.iloc[:-1]
if len(m) < self.n + 2:
return
ca, cb = m["ca"].values, m["cb"].values
ca, cb = m["close_a"].values, m["close_b"].values
z, dr = self._zscore(ca, cb)
i = len(m) - 1
cur_ts = int(m["timestamp"].iloc[i])
@@ -306,19 +319,29 @@ class PairsWorker:
if np.isnan(zi):
self._save_state(); return
# flat della barra corrente (entrambe le gambe): O=H=L=C in una delle due
flat_i = False
if self.flat_skip and {"open_a", "high_a", "low_a"}.issubset(m.columns) \
and {"open_b", "high_b", "low_b"}.issubset(m.columns):
fa = (m["open_a"].iloc[i] == m["high_a"].iloc[i] == m["low_a"].iloc[i] == ca[i])
fb = (m["open_b"].iloc[i] == m["high_b"].iloc[i] == m["low_b"].iloc[i] == cb[i])
flat_i = bool(fa or fb)
if self.in_position:
if cur_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = cur_ts
if abs(zi) <= self.z_exit:
self._close(float(ca[i]), float(cb[i]), float(zi), "mean_revert")
elif self.bars_held >= self.max_bars:
self._close(float(ca[i]), float(cb[i]), float(zi), "time_limit")
# arma l'uscita: |z|<=z_exit (rientro) o time-limit; poi esegui alla 1a barra pulita
if not self.exit_ready and (abs(zi) <= self.z_exit or self.bars_held >= self.max_bars):
self.exit_ready = True
if self.exit_ready and not flat_i:
reason = "mean_revert" if abs(zi) <= self.z_exit else "time_limit"
self._close(float(ca[i]), float(cb[i]), float(zi), reason)
self._save_state()
return
# flat: cerca ingresso (no look-ahead: z[i] usa solo dati <= i)
if dr[i] <= self.jump_max:
# cerca ingresso (no look-ahead: z[i] usa solo dati <= i); mai su barra stale
if dr[i] <= self.jump_max and not flat_i:
if zi <= -self.z_in:
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
elif zi >= self.z_in: