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
@@ -0,0 +1,24 @@
import pytest
from src.portfolio.base import Portfolio, SleeveSpec
from scripts.analysis.report_families import build_everything
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
def _master9_specs():
fade = [SleeveSpec(kind="single", name=f"{c}", sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
honest = [SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation")]
return fade + honest
def test_master9_backtest_matches_report():
p = Portfolio(code="PORT03", label="Master", sleeves=_master9_specs(), weighting="equal")
res = p.backtest()
S, _, _, _ = build_everything()
dr_ref = port_returns(S)
ref_full, ref_oos = metrics(dr_ref), metrics(dr_ref, lo=SPLIT)
assert res.full["sharpe"] == pytest.approx(ref_full["sharpe"], abs=1e-6)
assert res.full["dd"] == pytest.approx(ref_full["dd"], abs=1e-6)
assert res.oos["sharpe"] == pytest.approx(ref_oos["sharpe"], abs=1e-6)
@@ -0,0 +1,20 @@
import pytest
from scripts.portfolios._defs import PORTFOLIOS
def test_port06_cap_backtest_numbers_locked():
r = PORTFOLIOS["PORT06"].backtest()
# regression-lock dei numeri del default (cap pairs 0.33) — vedi report_families.
# Aggiornato 2026-05-31: il recupero dati BNB/DOGE/XRP (29 mag) ha ampliato la
# copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82,
# DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione.
# Aggiornato 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m
# flat-skip, mezza size) -> FULL 6.47->7.20, OOS 8.82->9.66, DD 4.1%->3.7%.
# Aggiornato 2026-06-09 (2): + XS01 (reversione cross-sectional 8 asset, PAPER) ->
# FULL 7.20->7.34, OOS 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.17pp).
# Aggiornato 2026-06-12: SWAP fade 1h->15m (combine_portfolio.FADE_TF, scelta utente,
# gate fade15m_port06_gate.py) -> FULL 7.34->8.13 DD 3.46->2.47, OOS 10.07->10.86.
# Nuovo baseline atteso, non una regressione.
assert r.full["sharpe"] == pytest.approx(8.13, abs=0.15)
assert r.oos["sharpe"] == pytest.approx(10.86, abs=0.25)
assert r.full["dd"] == pytest.approx(2.47, abs=0.5)
+29
View File
@@ -0,0 +1,29 @@
"""src.live.bars — detection condivisa della barra in formazione (lezione EXIT-16)."""
import numpy as np
from src.live.bars import bar_ms_of, last_bar_is_forming, last_settled_idx
H = 3_600_000
def _ts(n, end_ms):
return np.arange(end_ms - (n - 1) * H, end_ms + 1, H, dtype="int64")
def test_bar_ms_median():
assert bar_ms_of(_ts(50, 10 * H)) == H
assert bar_ms_of([1]) == 0 # troppo corta per stimare
def test_forming_vs_settled():
now = 1_000_000 * H
ts = _ts(60, now) # ultima barra appena aperta
assert last_bar_is_forming(ts, now_ms=now + 1) # in corso
assert last_settled_idx(ts, now_ms=now + 1) == -2
assert not last_bar_is_forming(ts, now_ms=now + H) # durata trascorsa
assert last_settled_idx(ts, now_ms=now + H) == -1
def test_degenerate_series_defaults_to_settled():
assert not last_bar_is_forming([], now_ms=0)
assert last_settled_idx([123], now_ms=0) == -1 # bar_ms non stimabile -> -1
+59
View File
@@ -0,0 +1,59 @@
import numpy as np
import pandas as pd
from src.live.basket_trend_worker import BasketTrendWorker
def _ramp_df(n=300, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_basket_goes_long_in_uptrend(tmp_path):
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
w.tick(data)
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0
def test_basket_flat_in_downtrend(tmp_path):
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
data = {"AAA": _ramp_df(slope=-1.0)}
w.tick(data)
assert w.positions["AAA"] == 0.0
def test_basket_persists_and_resumes(tmp_path):
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
w.tick({"AAA": _ramp_df(slope=1.0)})
w2 = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
assert w2.positions["AAA"] == 1.0 # stato ripreso da status.json
def _ramp_df_now(n=300, slope=1.0, forming_close=None):
"""Serie 4h che termina ADESSO: l'ultima riga e' la candela IN FORMAZIONE."""
import time
c = np.linspace(100, 100 + slope * n, n)
if forming_close is not None:
c = c.copy()
c[-1] = forming_close
bar = 4 * 3_600_000
now_ms = int(time.time() * 1000)
ts = now_ms - bar * np.arange(n - 1, -1, -1)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_basket_ignores_forming_bar(tmp_path):
# downtrend su barre COMPLETE (target flat); la candela in formazione e' un
# spike estremo che flipperebbe EMA20>EMA100 se contata -> va ignorata.
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
w.tick({"AAA": _ramp_df_now(slope=-0.3, forming_close=1e6)})
assert w.positions["AAA"] == 0.0
def test_basket_fee_includes_leverage(tmp_path):
# flip 0->1: fee = cap * POS * LEV * fee_rt/2 (reference honest_improve2:150)
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
w.tick({"AAA": _ramp_df(slope=1.0)})
expected = 1000.0 - 1000.0 * w.position_size * w.leverage * (w.fee_rt / 2)
assert np.isclose(w.capital, expected)
@@ -0,0 +1,49 @@
"""INIT_LINEAGE (2026-06-12): al primo avvio un worker eredita capital/real_capital
dal worker piu' recente di stessa strategia+asset su altro timeframe. Nato dallo
swap fade 1h->15m: i worker nuovi partivano dall'allocazione del pool scartando
il PnL del gemello (-16.8 di equity fantasma, riallineata a mano col seed)."""
import json
from types import SimpleNamespace
from src.live.strategy_worker import StrategyWorker
def _mk(tmp_path, tf, capital=100.0):
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
asset="BTC", tf=tf, capital=capital, position_size=0.5,
leverage=2.0, data_dir=tmp_path)
w._notify = lambda *a, **k: None
return w
def test_new_tf_inherits_capital_from_sibling(tmp_path):
old = _mk(tmp_path, "1h", capital=100.0)
old.capital = 181.19
old.real_capital = 181.18
old._save_state()
new = _mk(tmp_path, "15m", capital=174.50)
assert new.capital == 181.19
assert new.real_capital == 181.18
# la posizione NON si eredita
assert new.in_position is False
events = [json.loads(l)["event"] for l in new.trades_path.read_text().splitlines()]
assert "INIT_LINEAGE" in events
def test_no_sibling_starts_from_allocation(tmp_path):
w = _mk(tmp_path, "15m", capital=174.50)
assert w.capital == 174.50
events = [json.loads(l)["event"] for l in w.trades_path.read_text().splitlines()]
assert "INIT_LINEAGE" not in events
def test_resume_ignores_lineage(tmp_path):
old = _mk(tmp_path, "1h", capital=100.0)
old.capital = 999.0
old._save_state()
new = _mk(tmp_path, "15m", capital=174.50) # eredita 999
new.capital = 150.0
new._save_state()
again = _mk(tmp_path, "15m", capital=174.50) # RESUME, non lineage
assert again.capital == 150.0
+18
View File
@@ -0,0 +1,18 @@
import pytest
from src.live.cerbero_client import CerberoClient
@pytest.mark.network
def test_get_historical_v2_shape():
cli = CerberoClient()
candles = cli.get_historical_v2("BTC-PERPETUAL", "2026-05-25", "2026-05-27", "1h")
assert len(candles) > 0
c0 = candles[0]
assert {"timestamp", "open", "high", "low", "close", "volume"} <= set(c0)
@pytest.mark.network
def test_get_instruments_returns_list():
cli = CerberoClient()
inst = cli.get_instruments("ETH", "future")
assert isinstance(inst, list) and len(inst) > 0
@@ -0,0 +1,123 @@
"""EXIT-16 close-confirm SL (2026-06-04): col param `sl_confirm_atr` il wick che
buca lo SL NON stoppa piu' (immune ai wick); lo stop scatta solo se il CLOSE
sfonda sl ∓ buf*ATR14, con uscita al close. TP intrabar invariato.
Senza param il comportamento resta quello storico (regressione)."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(last_high, last_low, last_close, n=120, price=100.0):
c = [price] * n
h = [price] * n
l = [price] * n
h[-1] = last_high
l[-1] = last_low
c[-1] = last_close
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": [price] * n, "high": h, "low": l,
"close": c, "volume": 1.0})
def _long_worker(tmp, confirm=0.5):
params = {"sl_confirm_atr": confirm} if confirm else {}
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, params=params, data_dir=tmp)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 102.0
w.sl = 98.0
w.max_bars = 24
w.bars_held = 1
w.last_bar_ts = 0
return w
def test_wick_below_sl_does_not_stop(tmp_path):
# low 97 buca lo SL(98) ma il CLOSE rientra a 100 -> resta in posizione
w = _long_worker(tmp_path)
w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0))
assert w.in_position
def test_same_wick_stops_without_param(tmp_path):
# regressione: senza sl_confirm_atr lo stesso wick stoppa intrabar al livello
w = _long_worker(tmp_path, confirm=None)
w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0))
assert not w.in_position
def test_close_breach_stops_at_close(tmp_path):
# close 96.5 sfonda sl-buf (ATR del df flat ~ TR ultimo bar /14 -> buf piccolo) -> stop
w = _long_worker(tmp_path)
cap_before = w.capital
w.tick(_df(last_high=100.5, last_low=96.0, last_close=96.5))
assert not w.in_position
assert w.capital < cap_before # uscito al CLOSE (in perdita), non al livello
def test_tp_intrabar_still_first(tmp_path):
# high tocca il TP(102) intrabar -> esce al TP anche in modalita' close-confirm
w = _long_worker(tmp_path)
w.tick(_df(last_high=102.5, last_low=99.5, last_close=100.0))
assert not w.in_position
assert w.capital > 1000.0 # uscito al TP in profitto
def test_small_breach_within_buffer_holds(tmp_path):
# close appena sotto SL ma DENTRO il buffer ATR -> resta in posizione
w = _long_worker(tmp_path)
df = _df(last_high=100.0, last_low=94.0, last_close=97.95) # TR=6 -> ATR~0.43 -> buf~0.21
w.tick(df)
assert w.in_position
# ---------------------------------------------------------------- FIX 2026-06-05
# Il confirm va valutato sul close di barra COMPLETATA (come fade_base.backtest),
# non sul prezzo della barra in formazione: il dip intrabar che rientra NON deve
# stoppare (audit live 2026-06-05: 2 stop su 3 erano wick-stop da barra in corso).
def _df_forming(prev_close, forming_low, forming_close, n=120, price=100.0):
"""Ultima riga = candela IN CORSO (timestamp = ora corrente flooral'ora):
il worker deve valutare il confirm sulla riga -2 (completata)."""
c = [price] * n
h = [price] * n
l = [price] * n
c[-2] = prev_close
l[-1] = forming_low
c[-1] = forming_close
h[-1] = max(price, forming_close)
# ts in MILLISECONDI espliciti (Timestamp.now() ha risoluzione us: l'astype
# darebbe unita' sbagliate). Ultima barra = ora corrente floor -> in corso.
end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000)
ts = [end_ms - (n - 1 - i) * 3_600_000 for i in range(n)]
return pd.DataFrame({"timestamp": ts, "open": [price] * n, "high": h, "low": l,
"close": c, "volume": 1.0})
def test_forming_bar_dip_does_not_stop(tmp_path):
# barra in corso sprofonda sotto sl-buf (close corrente 96.5) ma la barra
# COMPLETATA (-2) ha chiuso a 100 -> NIENTE stop (prima del fix stoppava)
w = _long_worker(tmp_path)
w.tick(_df_forming(prev_close=100.0, forming_low=96.0, forming_close=96.5))
assert w.in_position
def test_completed_close_breach_stops_even_if_recovered(tmp_path):
# la barra COMPLETATA ha chiuso sotto sl-buf; la barra in corso e' rimbalzata
# sopra -> stop comunque (fill al prezzo corrente, come lag_close_exit)
w = _long_worker(tmp_path)
w.tick(_df_forming(prev_close=96.0, forming_low=98.5, forming_close=99.0))
assert not w.in_position
def test_tp_intrabar_on_forming_bar_unchanged(tmp_path):
# TP toccato dalla barra in corso -> esce al TP (intrabar invariato)
w = _long_worker(tmp_path)
df = _df_forming(prev_close=100.0, forming_low=99.5, forming_close=100.0)
df.loc[df.index[-1], "high"] = 102.5
w.tick(df)
assert not w.in_position
assert w.capital > 1000.0
+10
View File
@@ -0,0 +1,10 @@
from src.portfolio.base import load_active_portfolio
def test_load_active_applies_overrides(tmp_path):
cfg = tmp_path / "portfolios.yml"
cfg.write_text("active: PORT06\noverrides:\n leverage: 2\n total_capital: 500\n")
p = load_active_portfolio(cfg)
assert p.code == "PORT06"
assert p.leverage == 2.0
assert p.total_capital == 500
+20
View File
@@ -0,0 +1,20 @@
from scripts.portfolios._defs import PORTFOLIOS
def test_six_portfolios_defined():
assert set(PORTFOLIOS) == {"PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"}
def test_port06_is_master_shape_cap():
p = PORTFOLIOS["PORT06"]
sids = set(p.sleeve_ids)
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC", "PR_ETHBTC_15M", "XS01"} <= sids
# 19 dal 2026-06-09: + XS01 (reversione cross-sectional 8 asset, sleeve PAPER, family XSEC)
assert len(sids) == 19
# SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata
# (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute)
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588}
def test_default_leverage_sober():
assert PORTFOLIOS["PORT06"].leverage == 2.0
+13
View File
@@ -0,0 +1,13 @@
import pandas as pd
from src.data.downloader import load_data
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
def test_dip01_generates_long_signals_with_exits():
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
assert len(sigs) > 0
s = sigs[0]
assert s.direction == 1
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
+73
View File
@@ -0,0 +1,73 @@
"""Guard FEED_BOOK_GAP (2026-06-12): il feed candele e il book d'esecuzione possono
divergere (MR02_BTC: fill reale a -443 bps dal sim; wick TP_PHANTOM il caso opposto).
Alert per episodio con isteresi a soglia/2, fail-open su errori di rete."""
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.portfolio import runner
class _Client:
def __init__(self, marks):
self._marks = marks
self.calls = 0
def get_ticker_batch(self, instruments):
self.calls += 1
return {"tickers": [{"instrument_name": i, "mark_price": self._marks[i]}
for i in instruments if i in self._marks]}
class _Boom:
def get_ticker_batch(self, instruments):
raise RuntimeError("rete giu'")
INSTR = {"BTC": "BTC_USDC-PERPETUAL"}
def _feed(close):
return {"BTC": pd.DataFrame({"close": [100.0, close]})}
def _events(monkeypatch):
sent = []
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
lambda ev, data: sent.append((ev, data)))
return sent
def test_gap_alerts_once_per_episode(monkeypatch):
sent = _events(monkeypatch)
cl = _Client({"BTC_USDC-PERPETUAL": 60481.0})
alerted = set()
# feed a 63285 vs mark 60481 = il caso MR02 (~464 bps) -> alert, una volta sola
for _ in range(3):
runner._check_feed_book_gap(cl, _feed(63285.5), INSTR, 150.0, alerted)
assert len(sent) == 1 and sent[0][0] == "FEED_BOOK_GAP"
assert sent[0][1]["asset"] == "BTC" and sent[0][1]["gap_bps"] > 400
def test_recovery_with_hysteresis(monkeypatch):
sent = _events(monkeypatch)
cl = _Client({"BTC_USDC-PERPETUAL": 60000.0})
alerted = {"BTC"}
# gap fra soglia/2 e soglia: NESSUN recovery (isteresi)
runner._check_feed_book_gap(cl, _feed(60000 * 1.0090), INSTR, 150.0, alerted)
assert sent == [] and "BTC" in alerted
# gap sotto soglia/2 -> RIENTRATO
runner._check_feed_book_gap(cl, _feed(60010.0), INSTR, 150.0, alerted)
assert len(sent) == 1 and sent[0][1].get("status") == "RIENTRATO"
assert "BTC" not in alerted
def test_fail_open_and_no_instruments(monkeypatch):
sent = _events(monkeypatch)
runner._check_feed_book_gap(_Boom(), _feed(63285.5), INSTR, 150.0, set())
cl = _Client({"BTC_USDC-PERPETUAL": 60481.0})
runner._check_feed_book_gap(cl, {}, INSTR, 150.0, set()) # nessun feed
assert sent == [] and cl.calls == 0
+70
View File
@@ -0,0 +1,70 @@
"""Gate feed CONGELATO (2026-06-15): salta entry+exit degli sleeve concentrati
(single/ml/pairs) il cui feed di DECISIONE 1h e' fermo (guasto, es. ETH-PERPETUAL
a 1661.95 per 36h+). Distingue il GUASTO (close mai cambiata per decine di barre)
dall'ILLIQUIDITA' (SOL/LTC/ADA: molte barre flat ma il prezzo si muove -> run corte).
Auto-guarente: la barra di ripresa e' non-flat -> il gate si rilascia da solo."""
import pandas as pd
from src.portfolio.runner import _frozen_assets, _feed_gated_sids
def _df(completed, forming):
"""Serie 1h: barre complete (flat = O=H=L=C; non-flat quando il prezzo cambia)
+ una candela in formazione (ultima riga, esclusa dal detector)."""
end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000)
vals = list(completed) + [forming]
n = len(vals)
rows = []
for j, v in enumerate(vals):
ts = end_ms - (n - 1 - j) * 3_600_000
rows.append({"timestamp": ts, "open": v, "high": v, "low": v, "close": v, "volume": 0.0})
for j in range(1, n): # bar NON-flat quando il prezzo si muove
if rows[j]["close"] != rows[j - 1]["close"]:
rows[j]["high"] = rows[j]["close"] * 1.001
return pd.DataFrame(rows)
def test_frozen_feed_detected():
raw = {"ETH": _df([1661.95] * 40, 1661.95)} # prezzo MAI cambiato (guasto)
assert _frozen_assets(raw, 12) == {"ETH"}
def test_live_feed_not_frozen():
raw = {"BTC": _df([65000 + i * 5 for i in range(40)], 65200.0)} # sempre in movimento
assert _frozen_assets(raw, 12) == set()
def test_illiquid_but_alive_not_frozen():
# ultima barra completa FLAT ma il prezzo si e' mosso da poche barre (run < soglia):
# alt illiquido (SOL/LTC/ADA), NON un guasto -> non gateato.
raw = {"LTC": _df([44.0] * 30 + [45.0] * 5, 45.0)}
assert _frozen_assets(raw, 12) == set()
def test_resume_releases_gate():
# rilascio solo su una barra COMPLETATA non-flat (la forming non basta -> coerente
# con la valutazione su barre settled, EXIT-16). NON e' l'entry-guard post-flat: la
# barra di ripresa NON viene saltata, il tick riprende su di essa.
raw = {"ETH": _df([1661.95] * 40 + [1700.0], 1700.0)}
assert _frozen_assets(raw, 12) == set()
# finche' la ripresa e' solo nella candela in formazione, resta congelato
assert _frozen_assets({"ETH": _df([1661.95] * 40, 1700.0)}, 12) == {"ETH"}
def test_threshold_zero_disables():
raw = {"ETH": _df([1661.95] * 40, 1661.95)}
assert _frozen_assets(raw, 0) == set()
def test_gated_scope_matches_eth_legs():
"""Sui veri sleeve PORT06: con ETH congelato si gatano TUTTE e sole le gambe ETH
(3 fade ETH + SH_ETH + 5 pairs con gamba ETH). BTC-only, BTC/LTC e i multi-asset
(anche ROT02 che ha ETH nell'universo) restano attivi."""
from scripts.portfolios._defs import FADE, PAIRS, SHAPE, HONEST
specs = FADE + PAIRS + SHAPE + HONEST
g = _feed_gated_sids(specs, {"ETH"})
assert {"MR01_ETH", "MR02_ETH", "MR07_ETH", "SH_ETH",
"PR_ETHBTC", "PR_ETHBTC_15M", "PR_LTCETH", "PR_ADAETH", "PR_ETHSOL"} <= g
assert g.isdisjoint({"MR01_BTC", "MR02_BTC", "MR07_BTC", "SH_BTC",
"DIP01_BTC", "PR_BTCLTC"})
assert g.isdisjoint({"TR01_basket", "ROT02_rot"}) # multi-asset mai gateati
assert _feed_gated_sids(specs, set()) == set() # nessun congelato -> nessun gate
+59
View File
@@ -0,0 +1,59 @@
"""Fix exit a orizzonte puro: una strategia che porta solo `max_bars` nella metadata del
Signal (niente TP/SL, es. SH01 shape-ML con H=12) deve uscire a `max_bars` barre — NON sul
fallback legacy `hold_bars=3`. Prima del fix lo StrategyWorker chiudeva tali sleeve a 3 barre
(reason "hold_limit"), tagliando l'orizzonte su cui è validato l'edge di forma."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(n, price=100.0, last_ts=None):
c = [price] * n
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
df = pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
if last_ts is not None:
df.loc[df.index[-1], "timestamp"] = last_ts
return df
def _horizon_worker(tmp, max_bars=12):
"""Worker con posizione aperta, solo max_bars (tp/sl=0) — come SH01."""
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 0.0
w.sl = 0.0
w.max_bars = max_bars
w.bars_held = 0
w.last_bar_ts = 0
return w
def _tick_bars(w, k, base_n=120):
"""Avanza k barre nuove (ogni tick incrementa bars_held di 1 col nuovo timestamp)."""
for b in range(1, k + 1):
w.tick(_df(base_n, last_ts=b))
def test_holds_until_horizon_not_hold_bars(tmp_path):
"""max_bars=12: a 3 barre (vecchio hold_bars) NON deve chiudere; deve restare in posizione."""
w = _horizon_worker(tmp_path, max_bars=12)
_tick_bars(w, 3)
assert w.in_position
assert w.bars_held == 3
def test_exits_at_max_bars_with_time_limit(tmp_path):
"""A max_bars barre chiude con reason 'time_limit' (non 'hold_limit')."""
w = _horizon_worker(tmp_path, max_bars=12)
_tick_bars(w, 12)
assert not w.in_position
# leggi l'ultimo CLOSE dal log (formato piatto) e verificane reason/bars_held
import json
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
close = [r for r in rows if r.get("event") == "CLOSE"]
assert close and close[-1]["reason"] == "time_limit"
assert close[-1]["bars_held"] == 12
@@ -0,0 +1,51 @@
"""Loss-guard Hurst: le fade saltano i segnali in regime persistente/trending (rolling-Hurst >=
soglia), dove si concentrano stop-loss e perdite. Validato 2026-06-02: filtrare hurst>=0.55
DIMEZZA il DD del PORT06 alzando lo Sharpe. Filtro CAUSALE (close<=i), default off (None)."""
import numpy as np
import pandas as pd
from src.strategies.fade_base import hurst_skip_mask
def _df(close):
n = len(close)
return pd.DataFrame({"timestamp": range(n), "open": close, "high": close,
"low": close, "close": close, "volume": [1.0] * n})
def test_mask_off_when_none():
df = _df(np.cumsum(np.random.default_rng(0).normal(size=400)) + 100)
m = hurst_skip_mask(df, None)
assert m.dtype == bool and not m.any() # None -> nessuno skip
def test_mask_flags_persistent_regime():
# serie fortemente TRENDING (persistente, Hurst alto) -> deve essere mascherata (skip) molto
trend = np.linspace(100, 300, 600)
df = _df(trend)
m = hurst_skip_mask(df, hurst_max=0.55, window=100)
# dopo il warmup, una rampa pulita e' persistente -> gran parte mascherata
assert m[150:].mean() > 0.5
def test_fade_strategy_filters_signals():
"""Una fade con hurst_max produce <= segnali del baseline, e tutti i superstiti sono in
regime non-persistente (la maschera e' False alla loro barra)."""
import importlib
rng = np.random.default_rng(1)
# serie mean-reverting (anti-persistente) con qualche estensione -> genera fade
n = 1200
c = 100 + np.cumsum(rng.normal(scale=0.5, size=n))
c = 100 + (c - c.mean()) * 0.3 # comprimi verso la media (mean-revert)
df = _df(c)
ts = pd.to_datetime(df["timestamp"], unit="s", utc=True)
m = importlib.import_module("scripts.strategies.MR01_bollinger_fade")
Strat = next(v for k, v in vars(m).items()
if isinstance(v, type) and getattr(v, "__module__", "") == m.__name__
and hasattr(v, "generate_signals"))
s = Strat()
base = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0)
filt = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0, hurst_max=0.55)
assert len(filt) <= len(base) # il filtro non aggiunge mai segnali
skip = hurst_skip_mask(df, 0.55, 100)
assert all(not skip[sig.idx] for sig in filt) # nessun superstite in regime persistente
+56
View File
@@ -0,0 +1,56 @@
"""Fix gap intrabar: lo StrategyWorker esce su TP/SL toccati INTRABAR (high/low della barra),
al livello, come il backtest — non solo quando il close supera il livello."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(last_high, last_low, n=120, price=100.0):
c = [price] * n
h = [price] * n
l = [price] * n
h[-1] = last_high
l[-1] = last_low
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": h, "low": l, "close": c, "volume": 1.0})
def _long_worker(tmp):
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 102.0
w.sl = 98.0
w.max_bars = 24
w.bars_held = 1
w.last_bar_ts = 0
return w
def test_long_exits_at_tp_on_intrabar_high(tmp_path):
w = _long_worker(tmp_path)
# close=100 (dentro la banda) ma high tocca 102.5 -> con il fix esce a TP
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
def test_long_exits_at_sl_on_intrabar_low(tmp_path):
w = _long_worker(tmp_path)
w.tick(_df(last_high=100.5, last_low=97.0)) # low sotto SL -> stop
assert not w.in_position
def test_long_holds_when_bar_within_band(tmp_path):
w = _long_worker(tmp_path)
w.tick(_df(last_high=101.0, last_low=99.0)) # né TP né SL toccati -> resta in posizione
assert w.in_position
def test_sl_has_priority_over_tp_same_bar(tmp_path):
w = _long_worker(tmp_path)
# barra che tocca SIA tp(102) SIA sl(98): conservativo -> SL prima
w.tick(_df(last_high=103.0, last_low=97.0))
assert not w.in_position # uscito (allo stop, ramo SL valutato per primo)
+76
View File
@@ -0,0 +1,76 @@
"""INVERTED_TP_SKIP (2026-06-16): un wick transitorio puo' far calcolare alla strategia
un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra wickata, entry al
prezzo recuperato gia' oltre il proprio tp). L'exit intrabar `bar_high>=tp` (long) /
`bar_low<=tp` (short) scatterebbe a bars_held=0 in PERDITA, con churn di fee e TP
reduce-only respinti. Verita' d'esecuzione, zero parametri: se il TP e' gia' sfondato
all'ingresso il segnale e' malformato -> NON si apre. TP dal lato giusto (o assente)
-> comportamento storico."""
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
from src.strategies.base import Signal
def _worker(tmp):
w = StrategyWorker(strategy=load_strategy("MR02_donchian_fade"), asset="BTC", tf="15m",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
return w
def test_long_inverted_tp_skipped(tmp_path):
# long con tp SOTTO l'entry (caso reale 16-06: entry 66780, tp 66189) -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "sl": 64218.21, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w.tp == 0.0
def test_short_inverted_tp_skipped(tmp_path):
# short con tp SOPRA l'entry -> il TP intrabar `bar_low<=tp` scatterebbe subito -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 105.0, "sl": 110.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert not w.in_position
def test_long_valid_tp_opens(tmp_path):
# controllo positivo: long con tp SOPRA l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0,
metadata={"tp": 103.0, "sl": 98.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == 1
assert w.tp == 103.0
def test_short_valid_tp_opens(tmp_path):
# controllo positivo: short con tp SOTTO l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 97.0, "sl": 102.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == -1
def test_no_tp_opens(tmp_path):
# segnale senza tp (strategie a orizzonte puro, es. SH01) -> il guard non si applica
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0, metadata={"max_bars": 12})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
def test_inverted_skip_deduped_per_bar(tmp_path):
# stesso bar_ts -> un solo log INVERTED_TP_SKIP; non blocca un bar successivo valido
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w._inverted_tp_ts == 1
+43
View File
@@ -0,0 +1,43 @@
from pathlib import Path
from src.portfolio.ledger import PortfolioLedger
def test_alloc_split_by_weights(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
alloc = L.allocate({"a": 0.6, "b": 0.4})
assert alloc == {"a": 600.0, "b": 400.0}
def test_alloc_conserves_total_with_reserved(tmp_path):
# un worker in posizione (reserved) trattiene capitale != alloc: i flat si
# dividono il RESTO per peso rinormalizzato e Σalloc == total (no doppio conteggio).
# Regressione del bug 2026-06-13 (MR02_BTC 15m in-pos al ribilancio: +4.77 fantasma).
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
alloc = L.allocate({"a": 0.5, "b": 0.25, "c": 0.25}, reserved={"a": 181.0})
assert abs(sum(alloc.values()) - 1000.0) < 1e-6 # equity conservata
assert alloc["a"] == 181.0 # in-pos tiene il suo
# b e c si dividono 819 per peso 0.25/0.25 -> 409.5 ciascuno
assert abs(alloc["b"] - 409.5) < 1e-6 and abs(alloc["c"] - 409.5) < 1e-6
def test_alloc_no_reserved_is_legacy(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
assert L.allocate({"a": 0.6, "b": 0.4}) == {"a": 600.0, "b": 400.0}
def test_update_tracks_equity_and_dd(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
L.update_equity({"a": 700.0, "b": 500.0})
assert L.equity == 1200.0 and L.peak == 1200.0 and L.max_dd == 0.0
L.update_equity({"a": 500.0, "b": 400.0})
assert L.equity == 900.0
assert abs(L.max_dd - 25.0) < 1e-9
def test_persist_and_resume(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
L.update_equity({"a": 1100.0})
L.save()
L2 = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
assert L2.equity == 1100.0 and L2.peak == 1100.0
assert (tmp_path / "PORTX" / "equity.jsonl").exists()
+81
View File
@@ -0,0 +1,81 @@
"""Filtro edge minimo (`min_tp_frac`): MR01/DIP01 NON devono emettere segnali il cui TP
(la media) cade entro `min_tp_frac` dall'entry — sarebbero perdenti garantiti netto fee.
Proprietà testate su dati reali BTC 1h:
1. monotonia: alzando min_tp_frac il numero di segnali non aumenta;
2. ogni segnale superstite ha gap TP > min_tp_frac;
3. con min_tp_frac=0 il comportamento è invariato (default off = backtest validato intatto).
"""
import numpy as np
import pytest
from src.data.downloader import load_data
from scripts.strategies.MR01_bollinger_fade import BollingerFade
import importlib
_dip_mod = importlib.import_module("scripts.strategies.DIP01_dip_buy")
DipCls = next(v for k, v in vars(_dip_mod).items()
if isinstance(v, type) and k.lower().startswith("dip"))
@pytest.fixture(scope="module")
def btc():
df = load_data("BTC", "1h")
return df, df.index # ts non usato dalle fade, basta un placeholder
def _gaps(signals, df):
c = df["close"].values
return [abs(s.metadata["tp"] - c[s.idx]) / c[s.idx] for s in signals]
def test_mr01_filter_monotone_and_gap(btc):
df, ts = btc
s = BollingerFade()
base = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
for f in (0.0010, 0.0015, 0.0020, 0.005):
sig = s.generate_signals(df, ts, **base, min_tp_frac=f)
assert len(sig) <= n0 # monotonia
gaps = _gaps(sig, df)
assert all(g > f for g in gaps) # nessun superstite sotto soglia
def test_mr01_default_off_unchanged(btc):
df, ts = btc
s = BollingerFade()
base = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
a = s.generate_signals(df, ts, **base) # default (no kw)
b = s.generate_signals(df, ts, **base, min_tp_frac=0.0)
assert len(a) == len(b)
def test_dip01_filter_gap(btc):
df, ts = btc
s = DipCls()
base = dict(n=50, z_in=2.0, sl_atr=2.5, max_bars=24)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
sig = s.generate_signals(df, ts, **base, min_tp_frac=0.0020)
assert len(sig) <= n0
assert all(g > 0.0020 for g in _gaps(sig, df))
def _load(mod_name):
import importlib
m = importlib.import_module(mod_name)
return next(v() for k, v in vars(m).items()
if isinstance(v, type) and getattr(v, "__module__", "") == m.__name__
and hasattr(v, "generate_signals"))
def test_mr02_mr07_filter_gap(btc):
"""Anche MR02 (midpoint canale) e MR07 (ATR-scaled) onorano min_tp_frac."""
df, ts = btc
for mod, base in (
("scripts.strategies.MR02_donchian_fade", dict(n=20, sl_atr=2.0, max_bars=24)),
("scripts.strategies.MR07_return_reversal",
dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
):
s = _load(mod)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
sig = s.generate_signals(df, ts, **base, min_tp_frac=0.0015)
assert len(sig) <= n0
assert all(g > 0.0015 for g in _gaps(sig, df))
+129
View File
@@ -0,0 +1,129 @@
"""Netting delle chiusure market (v1.1.25): close_amount tenta il reduce-only e
riesegue il RESIDUO in market puro quando il netting di conto lo cappa/respinge
(audit 2026-06-11: 3 gambe pairs orfane + 1 close cappato). Il chiamante riceve
UN Fill combinato. Nessuna rete: _submit monkeypatchato."""
from src.live.execution import ExecutionClient, Fill
def _fill(inst, side, amount, filled, px, verified=True, state="filled", fee=0.05):
return Fill(inst, side, 0.0, amount, px if filled else None, 0.0, fee,
f"oid-{side}-{filled}", state, verified, filled_amount=filled)
def _client_with(submits, allowance=float("inf")):
"""ExecutionClient con _submit finto e guard del netting stubbato (il guard
reale legge conto+libri via HTTP/filesystem; qui si inietta il gap)."""
ec = ExecutionClient()
calls = []
def fake_submit(instrument, side, amount, notional, reduce_only, label=None,
order_type="market", price=None):
calls.append(dict(amount=amount, reduce_only=reduce_only, label=label))
return submits.pop(0)(instrument, side, amount)
ec._submit = fake_submit
ec._net_close_allowance = lambda *a, **k: allowance
return ec, calls
INST = "ETH_USDC-PERPETUAL"
def test_full_reduce_only_no_fallback():
# caso normale: il reduce-only filla tutto -> NESSUN secondo ordine
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=a, px=1700.0),
])
f = ec.close_amount(INST, "sell", 0.103, label="w1")
assert len(calls) == 1 and calls[0]["reduce_only"] is True
assert f.verified and abs(f.filled_amount - 0.103) < 1e-9
assert "netting" not in (f.notes or "")
def test_capped_reduce_only_nets_residual():
# reduce-only CAPPATO (filla 0.078 su 0.105) -> residuo 0.027 in market puro
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.078, px=1700.0),
lambda i, s, a: _fill(i, s, a, filled=a, px=1702.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert calls[0]["reduce_only"] is True and calls[1]["reduce_only"] is False
assert abs(calls[1]["amount"] - 0.027) < 1e-9
assert calls[1]["label"] == "w1|net"
assert f.verified and abs(f.filled_amount - 0.105) < 1e-9
# prezzo medio pesato: (0.078*1700 + 0.027*1702) / 0.105
exp_px = (0.078 * 1700.0 + 0.027 * 1702.0) / 0.105
assert abs(f.fill_price - exp_px) < 1e-6
assert abs(f.fee_usd - 0.10) < 1e-9 # fee sommate
assert "netting" in f.notes
def test_rejected_reduce_only_nets_full_amount():
# reduce-only RESPINTO (error, 0 fill) -> tutto l'amount in market puro
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
lambda i, s, a: _fill(i, s, a, filled=a, px=1701.0),
])
f = ec.close_amount(INST, "buy", 0.027, label="pair")
assert len(calls) == 2 and abs(calls[1]["amount"] - 0.027) < 1e-9
assert f.verified and abs(f.filled_amount - 0.027) < 1e-9
assert f.fill_price == 1701.0
assert "netting" in f.notes
def test_net_leg_also_fails_reports_partial():
# anche il market puro fallisce -> Fill combinato NON verified, filled = solo r-o
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.05, px=1700.0),
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert not f.verified
assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger
def test_stale_state_guard_blocks_naked_order():
# quota gia' chiusa (DSL in outage / flatten manuale): reduce-only filla 0 e
# il gap conto-vs-libri e' 0 -> NESSUN ordine market nudo (code-review 2026-06-11)
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
], allowance=0.0)
f = ec.close_amount(INST, "buy", 0.105, label="w1")
assert len(calls) == 1 # solo il reduce-only, niente fallback
assert not f.verified and f.filled_amount == 0.0
assert "netting NEGATO" in f.notes
def test_guard_unknown_fails_safe():
# guard non calcolabile (rete): FAIL-SAFE, niente ordine nudo
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
], allowance=None)
f = ec.close_amount(INST, "buy", 0.105, label="w1")
assert len(calls) == 1
assert "non calcolabile" in f.notes
def test_guard_caps_residual_to_allowance():
# gap parziale: il residuo nudo e' cappato a quanto i libri giustificano
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
lambda i, s, a: _fill(i, s, a, filled=a, px=1700.0),
], allowance=0.027)
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert abs(calls[1]["amount"] - 0.027) < 1e-9
assert not f.verified # 0.027 < 0.105: chiusura parziale onesta
assert abs(f.filled_amount - 0.027) < 1e-9
def test_dust_residual_no_fallback():
# residuo da artefatto float (1e-17): NON deve diventare un lotto minimo nudo
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=a - 1e-17, px=1700.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 1
assert f.verified
+101
View File
@@ -0,0 +1,101 @@
"""PairsWorker esecuzione reale a 2 gambe (shadow): apertura/chiusura, leg-risk unwind,
ledger reale parallelo. Executor finto, nessuna rete."""
from types import SimpleNamespace
import numpy as np
import pandas as pd
import pytest
from src.live.execution import Fill, PairFill
from src.live.pairs_worker import PairsWorker
class FakePairsExec:
"""Simula PairsExecutionClient: fill deterministici, con possibilità di leg-fail."""
def __init__(self, fail_leg=None):
self.fail_leg = fail_leg # None | 'a' | 'b' : quale gamba NON filla all'open
self.opened = []
self.closed = []
self.unwound = []
def _fill(self, inst, side, price, amt=1.0, ok=True):
return Fill(inst, side, 0.0, amt if ok else 0.0, price if ok else None,
0.0, 0.05 if ok else 0.0, "oid" if ok else None,
"filled" if ok else "error", ok,
filled_amount=amt if ok else 0.0)
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
side_a = "buy" if direction == 1 else "sell"
side_b = "sell" if direction == 1 else "buy"
ok_a = self.fail_leg != 'a'
ok_b = self.fail_leg != 'b'
fa = self._fill(inst_a, side_a, 100.0, ok=ok_a)
fb = self._fill(inst_b, side_b, 50.0, ok=ok_b)
self.opened.append((inst_a, inst_b, direction))
if ok_a and ok_b:
return PairFill(True, fa, fb)
if ok_a and fa.amount > 0:
self.unwound.append(inst_a)
if ok_b and fb.amount > 0:
self.unwound.append(inst_b)
return PairFill(False, fa, fb, unwound=bool(self.unwound), notes="leg-fail")
def close_pair(self, inst_a, inst_b, side_a, side_b, amt_a, amt_b, label=None):
opp_a = "sell" if side_a == "buy" else "buy"
opp_b = "sell" if side_b == "buy" else "buy"
self.closed.append((inst_a, inst_b))
# prezzi di uscita: A salito a 102, B fermo a 50 -> long-A/short-B guadagna
return PairFill(True, self._fill(inst_a, opp_a, 102.0, amt_a),
self._fill(inst_b, opp_b, 50.0, amt_b))
INST = {"ETH": "ETH_USDC-PERPETUAL", "BTC": "BTC_USDC-PERPETUAL"}
def _worker(tmp_path, fake, monkeypatch, notified=None):
if notified is not None:
monkeypatch.setattr("src.live.pairs_worker.notify_event",
lambda ev, data=None: notified.append(ev))
return PairsWorker("ETH", "BTC", "1h", capital=200.0, position_size=0.2, leverage=2.0,
data_dir=tmp_path, executor=fake, exec_instruments=INST)
def test_execution_enabled_wired(tmp_path):
w = _worker(tmp_path, FakePairsExec(), pytest.MonkeyPatch())
assert w.execution_enabled and w.inst_a == "ETH_USDC-PERPETUAL" and w.inst_b == "BTC_USDC-PERPETUAL"
def test_real_open_and_close_pair(tmp_path, monkeypatch):
notified = []
fake = FakePairsExec()
w = _worker(tmp_path, fake, monkeypatch, notified)
w._open(1, 100.0, 50.0, -2.5) # long ratio
assert w.real_in_position and w.real_dir == 1
assert w.real_side_a == "buy" and w.real_side_b == "sell"
assert fake.opened and "REAL_EXEC_LIVE" in notified
cap0 = w.real_capital
w._close(102.0, 50.0, 0.3, "mean_revert")
assert not w.real_in_position and fake.closed
assert w.real_capital > cap0 # A +2% / B 0 -> long-A/short-B in utile
assert w.real_trades == 1
def test_leg_fail_unwinds_and_no_position(tmp_path, monkeypatch):
notified = []
fake = FakePairsExec(fail_leg='b') # gamba B non filla
w = _worker(tmp_path, fake, monkeypatch, notified)
w._open(1, 100.0, 50.0, -2.5)
assert not w.real_in_position # niente posizione reale
assert "ETH_USDC-PERPETUAL" in fake.unwound # la gamba A fillata e' stata richiusa
assert "REAL_OPEN_FAIL" in notified
def test_real_ledger_persists_and_resumes(tmp_path, monkeypatch):
fake = FakePairsExec()
w = _worker(tmp_path, fake, monkeypatch, [])
w._open(-1, 100.0, 50.0, 2.5) # short ratio
assert w.real_in_position and w.real_dir == -1
w2 = PairsWorker("ETH", "BTC", "1h", capital=200.0, position_size=0.2, leverage=2.0,
data_dir=tmp_path, executor=fake, exec_instruments=INST)
assert w2.real_in_position and w2.real_dir == -1
assert w2.real_side_a == "sell" and w2.real_amount_a > 0
@@ -0,0 +1,41 @@
"""PairsWorker: entry/exit valutati SOLO su barre COMPLETE (lezione EXIT-16)."""
import time
import numpy as np
import pandas as pd
from src.live.pairs_worker import PairsWorker
def _pair_dfs(n=80, jump=0.05, forming=True):
"""Log-ratio = rumore alternato ±0.001 (z ~ ±1, nessun segnale) tranne l'ULTIMA
barra, dove un salto `jump` del ratio porta z >> z_in (e dr <= jump_max).
Con forming=True la serie termina ADESSO -> l'ultima riga e' in formazione."""
r = 0.001 * np.array([(-1) ** i for i in range(n)], dtype=float)
r[-1] = jump
ca = 100.0 * np.exp(r)
cb = np.full(n, 100.0)
if forming:
now_ms = int(time.time() * 1000)
ts = now_ms - 3_600_000 * np.arange(n - 1, -1, -1)
else:
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
dfa = pd.DataFrame({"timestamp": ts, "close": ca})
dfb = pd.DataFrame({"timestamp": ts, "close": cb})
return dfa, dfb
def test_pairs_opens_on_completed_extreme_bar(tmp_path):
# controllo: la stessa barra estrema, se COMPLETA, apre (z >= z_in, short ratio)
w = PairsWorker("AAA", "BBB", "1h", data_dir=tmp_path)
dfa, dfb = _pair_dfs(forming=False)
w.tick(dfa, dfb)
assert w.in_position and w.direction == -1
def test_pairs_ignores_forming_bar(tmp_path):
# la stessa barra estrema IN FORMAZIONE va ignorata: nessun ingresso
w = PairsWorker("AAA", "BBB", "1h", data_dir=tmp_path)
dfa, dfb = _pair_dfs(forming=True)
w.tick(dfa, dfb)
assert not w.in_position
+151
View File
@@ -0,0 +1,151 @@
"""_real_close: cancel del disaster-SL (retry + alert orfano) e alert REAL_DIVERGENCE.
Usa un executor finto: nessuna rete, nessun ordine reale.
"""
from types import SimpleNamespace
import pytest
from src.live.execution import Fill
from src.live.strategy_worker import StrategyWorker
class FakeExec:
verify_polls = 1
verify_sleep = 0.0
disaster_sl_pct = 0.30
def __init__(self, cancel_responses):
self.cancel_calls = []
self._responses = list(cancel_responses)
def cancel_order(self, oid):
self.cancel_calls.append(oid)
return self._responses.pop(0) if self._responses else {"state": "error", "error": "boom"}
def resting_fills(self, instrument, oid):
return 0.0, None, 0.0
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0,
"oid-close", "filled", True, filled_amount=amount)
class FakeFullExec(FakeExec):
"""Aggiunge open/place_tp_limit/place_disaster_sl per testare il ciclo SH01 completo."""
def __init__(self):
super().__init__([{"state": "untriggered"}])
self.tp_calls = 0
self.dsl_calls = 0
self.close_amounts = []
def open(self, instrument, side, notional, label=None):
amt = round(notional / 100.0, 6)
return Fill(instrument, side, notional, amt, 100.0, 0.0, 0.05, "oid-open", "filled", True)
def place_tp_limit(self, *a, **k):
self.tp_calls += 1 # NON deve essere chiamato per SH01 (no TP)
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def place_disaster_sl(self, instrument, side, amount, stop, label=None):
self.dsl_calls += 1
return Fill(instrument, "sell", 0.0, amount, None, 0.0, 0.0, "dsl-1", "untriggered", True)
def close_amount(self, instrument, side, amount, label=None):
self.close_amounts.append(amount)
return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
"filled", True, filled_amount=amount)
def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch):
"""SH01 (no TP/SL, exit a orizzonte): apre reale, NON piazza TP, piazza disaster,
chiude TUTTA la quota a market reduce-only."""
from src.live.strategy_worker import StrategyWorker
from src.strategies.base import Signal
notified = []
monkeypatch.setattr("src.live.strategy_worker.notify_event",
lambda ev, data=None: notified.append(ev))
fake = FakeFullExec()
w = StrategyWorker(strategy=SimpleNamespace(name="SH01_shape_ml", fee_rt=0.001),
asset="BTC", tf="1h", capital=100.0, position_size=0.5, leverage=2.0,
data_dir=tmp_path, executor=fake, exec_instrument="BTC_USDC-PERPETUAL")
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
metadata={"max_bars": 12}), 100.0) # niente tp/sl
assert w.real_in_position and fake.tp_calls == 0 # nessun TP per SH01
assert fake.dsl_calls == 1 and w.real_dsl_order_id # disaster bracket sì
amt = w.real_amount
w._close_position(105.0, "time_limit")
assert not w.real_in_position
assert fake.close_amounts == [amt] # chiude TUTTA la quota a market
def _worker(tmp_path, fake, monkeypatch, notified):
monkeypatch.setattr("src.live.strategy_worker.notify_event",
lambda ev, data=None: notified.append(ev))
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
asset="BTC", tf="1h", capital=100.0, data_dir=tmp_path,
executor=fake, exec_instrument="BTC_USDC-PERPETUAL")
w.real_in_position = True
w.real_side = "buy"
w.real_amount = 0.001
w.real_entry_price = 100.0
w.real_entry_notional = 0.1
w.real_dsl_order_id = "DSL-1"
return w
def test_dsl_cancel_untriggered_is_success(tmp_path, monkeypatch):
notified = []
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(100.0, "time_limit", 0.0)
assert fake.cancel_calls == ["DSL-1"] # nessun retry
assert "REAL_DSL_CANCEL_FAIL" not in notified
assert w.real_dsl_order_id == ""
def test_dsl_cancel_not_found_logs_but_no_alert(tmp_path, monkeypatch):
# order_not_found = probabile trigger durante outage: log, NIENTE Telegram, no retry
notified = []
fake = FakeExec([{"order_id": "DSL-1", "state": "error", "error": "order_not_found"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(100.0, "time_limit", 0.0)
assert fake.cancel_calls == ["DSL-1"]
assert "REAL_DSL_CANCEL_FAIL" not in notified
def test_dsl_cancel_transient_error_retries_then_succeeds(tmp_path, monkeypatch):
notified = []
fake = FakeExec([{"state": "error", "error": "timeout"},
{"order_id": "DSL-1", "state": "cancelled"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(100.0, "time_limit", 0.0)
assert fake.cancel_calls == ["DSL-1", "DSL-1"] # retry eseguito
assert "REAL_DSL_CANCEL_FAIL" not in notified
def test_dsl_cancel_persistent_error_alerts_orphan(tmp_path, monkeypatch):
notified = []
fake = FakeExec([{"state": "error", "error": "timeout"},
{"state": "error", "error": "timeout"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(100.0, "time_limit", 0.0)
assert fake.cancel_calls == ["DSL-1", "DSL-1"]
assert "REAL_DSL_CANCEL_FAIL" in notified # stop forse orfano -> Telegram
def test_real_divergence_alert_on_close(tmp_path, monkeypatch):
# fill reale 100.0 vs sim_exit 105.0 -> ~-476bps -> REAL_DIVERGENCE
notified = []
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(105.0, "take_profit", 0.5)
assert "REAL_DIVERGENCE" in notified
def test_no_divergence_alert_when_fill_matches(tmp_path, monkeypatch):
notified = []
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
w = _worker(tmp_path, fake, monkeypatch, notified)
w._real_close(100.05, "take_profit", 0.0) # ~-5bps: sotto soglia
assert "REAL_DIVERGENCE" not in notified
+239
View File
@@ -0,0 +1,239 @@
"""REAL-TRUTH (2026-06-10): col flag attivo il ledger `capital` dei worker eseguiti
si aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta diagnostica nel
log CLOSE (pnl_source/sim_pnl/real_pnl). Fallback dichiarato al sim solo se il trade
reale non e' mai esistito/fillato. Executor finti, nessuna rete."""
import json
from types import SimpleNamespace
from src.live.execution import Fill, PairFill
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
# ---------------- helpers ----------------
class FakeExec:
"""Single-leg: open filla a `open_px`, close a `close_px` (fee fisse)."""
verify_polls = 1
verify_sleep = 0.0
disaster_sl_pct = None
def __init__(self, open_px=100.0, close_px=100.0, close_verified=True):
self.open_px = open_px
self.close_px = close_px
self.close_verified = close_verified
def open(self, instrument, side, notional, label=None):
amt = round(notional / self.open_px, 6)
return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05,
"oid-open", "filled", True, filled_amount=amt)
def place_tp_limit(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def place_disaster_sl(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def cancel_order(self, oid):
return {"state": "cancelled"}
def resting_fills(self, instrument, oid):
return 0.0, None, 0.0
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified,
filled_amount=amount)
def _sw(tmp_path, fake, real_truth=True):
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
asset="BTC", tf="1h", capital=100.0, position_size=0.5,
leverage=2.0, data_dir=tmp_path, executor=fake,
exec_instrument="BTC_USDC-PERPETUAL", real_truth=real_truth)
w._notify = lambda *a, **k: None
return w
def _last_close(w):
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
return [r for r in rows if r.get("event") == "CLOSE"][-1]
def _open_sim(w, price=100.0):
from src.strategies.base import Signal
w._open_position(Signal(idx=0, direction=1, entry_price=price,
metadata={"max_bars": 12}), price)
# ---------------- StrategyWorker ----------------
def test_capital_updates_from_real_fills(tmp_path):
"""Fill reali 100->105: capital += real_pnl (gross sul notional reale - fee reali),
NON il sim pnl."""
fake = FakeExec(open_px=100.0, close_px=105.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
assert w.real_in_position
notional = w.real_entry_notional # = capital*ps*lev = 100
w._close_position(105.0, "time_limit")
real_pnl = 0.05 * notional - 0.10 # +5% su notional - fee 2x0.05
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
c = _last_close(w)
assert c["pnl_source"] == "real"
assert abs(c["real_pnl"] - real_pnl) < 1e-3
assert c["win"] is True and w.total_wins == 1
def test_real_loss_counts_as_loss_even_if_sim_wins(tmp_path):
"""Divergenza sim/reale: il sim vede +5% (exit sim 105) ma il fill reale e' 99
-> in real-truth il trade e' LOSS e il capitale scende."""
fake = FakeExec(open_px=100.0, close_px=99.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
w._close_position(105.0, "take_profit") # sim esce a 105 (win sim)
c = _last_close(w)
assert c["pnl_source"] == "real"
assert c["real_pnl"] < 0 and c["win"] is False
assert w.capital < 100.0
assert c["sim_pnl"] > 0 # il sim avrebbe bookato un win
def test_fallback_sim_when_real_open_failed(tmp_path):
"""REAL_OPEN_FAIL (nessuna posizione reale): il ledger usa il sim, con flag."""
class FailOpen(FakeExec):
def open(self, instrument, side, notional, label=None):
return Fill(instrument, side, notional, 0.0, None, 0.0, 0.0,
None, "error", False, notes="boom")
w = _sw(tmp_path, FailOpen())
_open_sim(w, 100.0)
assert not w.real_in_position
w._close_position(105.0, "time_limit")
c = _last_close(w)
assert c["pnl_source"] == "sim_fallback"
assert c["pnl"] == c["sim_pnl"]
assert w.capital > 100.0 # sim pnl applicato
def test_shadow_mode_unchanged_without_flag(tmp_path):
"""real_truth=False (default storico): capital segue il SIM, real_capital il reale."""
fake = FakeExec(open_px=100.0, close_px=99.0)
w = _sw(tmp_path, fake, real_truth=False)
_open_sim(w, 100.0)
w._close_position(105.0, "take_profit")
c = _last_close(w)
assert "pnl_source" not in c
assert w.capital > 100.0 # sim win applicato al ledger
assert w.real_capital < 100.0 # reale in perdita, separato
# ---------------- PairsWorker ----------------
class FakePairsExec:
def __init__(self, open_a=100.0, open_b=50.0, close_a=102.0, close_b=50.0):
self.px = dict(open_a=open_a, open_b=open_b, close_a=close_a, close_b=close_b)
def _fill(self, inst, side, amount, px):
return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True,
filled_amount=amount)
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
sa = "buy" if direction == 1 else "sell"
sb = "sell" if direction == 1 else "buy"
return PairFill(True,
self._fill(inst_a, sa, notional / self.px["open_a"], self.px["open_a"]),
self._fill(inst_b, sb, notional / self.px["open_b"], self.px["open_b"]))
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
return PairFill(True,
self._fill(inst_a, "sell", amount_a, self.px["close_a"]),
self._fill(inst_b, "buy", amount_b, self.px["close_b"]))
def _pw(tmp_path, fake, real_truth=True):
w = PairsWorker(asset_a="ETH", asset_b="BTC", tf="1h", capital=100.0,
position_size=0.2, leverage=2.0, data_dir=tmp_path,
executor=fake, exec_instruments={"ETH": "ETH_USDC-PERPETUAL",
"BTC": "BTC_USDC-PERPETUAL"},
real_truth=real_truth)
w._notify = lambda *a, **k: None
return w
def test_pairs_capital_updates_from_real_fills(tmp_path):
"""Long ratio, gamba A +2% reale: capital += real_pnl (2 gambe, 4 fee da 0.02)."""
fake = FakePairsExec(open_a=100.0, close_a=102.0)
w = _pw(tmp_path, fake)
w._open(1, 100.0, 50.0, -2.1)
assert w.real_in_position
na = w.real_notional_a
w._close(102.0, 50.0, 0.1, "mean_revert")
real_pnl = 0.02 * na - 4 * 0.02 # +2% gamba A - fee 4 lati
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"CLOSE"' in l][-1])
assert c["pnl_source"] == "real"
assert w.total_trades == 1
def test_pairs_fallback_sim_on_leg_fail(tmp_path):
"""Apertura reale fallita (leg-risk unwound): chiusura su ledger sim con flag."""
class FailOpen(FakePairsExec):
def open_pair(self, *a, **k):
bad = Fill("x", "buy", 0.0, 0.0, None, 0.0, 0.0, None, "error", False)
return PairFill(False, bad, bad, unwound=False, notes="leg-fail")
w = _pw(tmp_path, FailOpen())
w._open(1, 100.0, 50.0, -2.1)
assert not w.real_in_position
w._close(102.0, 50.0, 0.1, "mean_revert")
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"CLOSE"' in l][-1])
assert c["pnl_source"] == "sim_fallback"
assert c["pnl"] == c["sim_pnl"]
def test_partial_close_books_only_filled_amount(tmp_path):
"""Reduce-only cappato dal netting (audit 2026-06-11): close richiesto 1.0 ma
fillato 0.5 -> il ledger booka SOLO il fillato, REAL_CLOSE verified=False e
evento REAL_CLOSE_PARTIAL col residuo orfano (niente piu' chiusure 'piene' finte)."""
class PartialFake(FakeExec):
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
self.close_px, 0.0, 0.05, "oid-close", "filled", True,
filled_amount=amount * 0.5)
fake = PartialFake(open_px=100.0, close_px=105.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
w._close_position(105.0, "time_limit")
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
rc = [r for r in rows if r.get("event") == "REAL_CLOSE"][-1]
assert rc["verified"] is False # chiusura NON completa
partial = [r for r in rows if r.get("event") == "REAL_CLOSE_PARTIAL"]
assert partial and partial[-1]["residuo_orfano"] > 0
def test_pairs_orphan_leg_close_not_booked(tmp_path):
"""Gamba A respinta in CHIUSURA (reduce-only vs netting, audit 2026-06-11): il suo
'profitto' al prezzo sim NON va nel ledger reale, l'orfano e' registrato/persistito
e il real-truth ricade sul sim DICHIARATO (applied=False)."""
class LegFailClose(FakePairsExec):
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
bad_a = Fill(inst_a, "sell", 0.0, amount_a, None, 0.0, 0.0, None, "error", False)
ok_b = self._fill(inst_b, "buy", amount_b, self.px["close_b"])
return PairFill(False, bad_a, ok_b)
fake = LegFailClose(open_a=100.0, close_a=110.0) # gamba A +10% ma MAI chiusa
w = _pw(tmp_path, fake)
w._open(1, 100.0, 50.0, -2.1)
na = w.real_notional_a
w._close(110.0, 50.0, 0.1, "mean_revert")
assert w.orphan_legs and w.orphan_legs[0]["instrument"] == "ETH_USDC-PERPETUAL"
rc = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"REAL_CLOSE_PAIR"' in l][-1])
assert rc["leg_a_ok"] is False and rc["leg_b_ok"] is True
# il +10% della gamba A (mai eseguita) NON e' nel capitale reale
assert w.real_capital < 100.0 + 0.10 * na - 0.05
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"event": "CLOSE"' in l][-1])
assert c["pnl_source"] == "sim_fallback"
@@ -0,0 +1,133 @@
"""Reconcile degli ordini RESTING (2026-06-12): TP/DSL attesi dai libri vs ordini
in book + fill non bookati (caso MR02_BTC: TP resting fillato di notte e disaster-SL
sparito, scoperti solo al close sim ore dopo). Nessuna rete: client stubbato,
PAPER puntato su tmp_path."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.live import books
class _Client:
def __init__(self, open_orders=None, trades=None):
self._oo = open_orders or []
self._tr = trades or []
def get_open_orders(self, currency="USDC", type="all"):
# merge 'all'+'trigger_all' nel chiamante: qui si ritorna tutto due volte
return self._oo
def get_trade_history(self, limit=100, instrument_name=None):
return [t for t in self._tr
if not instrument_name or t["instrument"] == instrument_name]
def _mk_worker(root: Path, wid: str, st: dict):
d = root / wid
d.mkdir(parents=True)
(d / "status.json").write_text(json.dumps(st))
def _setup(tmp_path, monkeypatch, statuses: dict):
root = tmp_path / "portfolio_paper"
for wid, st in statuses.items():
_mk_worker(root, wid, st)
monkeypatch.setattr(books, "PAPER", root)
import importlib
ra = importlib.import_module("scripts.analysis.reconcile_account")
monkeypatch.setattr(ra, "PAPER", root)
return ra
IN_POS = {"real_in_position": True, "real_tp_order_id": "TP-1",
"real_dsl_order_id": "DSL-1"}
def test_expected_resting_reads_single_leg(tmp_path, monkeypatch):
_setup(tmp_path, monkeypatch, {
"MR01_bollinger_fade__ETH__1h": IN_POS,
# pairs e worker flat: NESSUN resting atteso
"PR01_pairs_reversion__ETH_BTC__1h": {"real_in_position": True,
"real_amount_a": 0.1},
"MR02_donchian_fade__BTC__1h": {"real_in_position": False,
"real_tp_order_id": "TP-9"},
})
exp = books.expected_resting()
assert {(e["order_id"], e["kind"]) for e in exp} == {("TP-1", "tp"), ("DSL-1", "dsl")}
assert all(e["instrument"] == "ETH_USDC-PERPETUAL" for e in exp)
def test_resting_ok_when_on_book(tmp_path, monkeypatch):
ra = _setup(tmp_path, monkeypatch, {"MR01_bollinger_fade__ETH__1h": IN_POS})
cl = _Client(open_orders=[{"order_id": "TP-1", "label": "MR01_bollinger_fade__ETH__1h"},
{"order_id": "DSL-1", "label": "MR01_bollinger_fade__ETH__1h"}])
rows = ra.compute_resting_drift(cl)
assert [r["status"] for r in rows] == ["OK", "OK"]
def test_resting_filled_unbooked_vs_missing(tmp_path, monkeypatch):
# TP sparito dal book MA con fill nel trade history -> FILLED_UNBOOKED (caso MR02);
# DSL sparito senza fill (trigger genera order_id nuovo) -> MISSING
ra = _setup(tmp_path, monkeypatch, {"MR01_bollinger_fade__ETH__1h": IN_POS})
cl = _Client(open_orders=[],
trades=[{"instrument": "ETH_USDC-PERPETUAL", "order_id": "TP-1",
"amount": 0.103, "price": 1640.6}])
by_kind = {r["kind"]: r for r in ra.compute_resting_drift(cl)}
assert by_kind["tp"]["status"] == "FILLED_UNBOOKED"
assert abs(by_kind["tp"]["filled"] - 0.103) < 1e-9
assert by_kind["dsl"]["status"] == "MISSING"
def test_resting_stale_order_from_flat_worker(tmp_path, monkeypatch):
# ordine in book con label di un NOSTRO worker flat -> STALE (fillerebbe a sorpresa);
# ordini di altri bot (label sconosciuta) ignorati
ra = _setup(tmp_path, monkeypatch, {
"MR01_bollinger_fade__ETH__1h": {"real_in_position": False}})
cl = _Client(open_orders=[
{"order_id": "OLD-7", "label": "MR01_bollinger_fade__ETH__1h",
"instrument": "ETH_USDC-PERPETUAL", "order_type": "limit"},
{"order_id": "X-1", "label": "altro_bot", "instrument": "ETH_USDC-PERPETUAL"},
])
rows = ra.compute_resting_drift(cl)
assert len(rows) == 1
assert rows[0]["status"] == "STALE" and rows[0]["order_id"] == "OLD-7"
def _age(root: Path, wid: str, minutes: float):
"""Invecchia lo status.json di un worker di `minutes` minuti (mtime)."""
import os
p = root / wid / "status.json"
t = p.stat().st_mtime - minutes * 60
os.utime(p, (t, t))
def test_stale_real_position_flagged(tmp_path, monkeypatch):
# worker con posizione reale ma status.json vecchio = non gestito (caso
# MR02_BTC 1h ritirato dallo swap a 15m mentre short reale)
ra = _setup(tmp_path, monkeypatch, {
"MR02_donchian_fade__BTC__1h": {"real_in_position": True, "real_amount": 0.0028,
"real_side": "sell"}})
_age(ra.PAPER, "MR02_donchian_fade__BTC__1h", 800)
stale = ra.compute_stale_real_positions(max_age_min=15)
assert len(stale) == 1
assert stale[0]["worker"] == "MR02_donchian_fade__BTC__1h"
assert stale[0]["real_amount"] == 0.0028
def test_fresh_real_position_not_flagged(tmp_path, monkeypatch):
# worker vivo (status.json appena scritto) NON e' stantio anche se in posizione
ra = _setup(tmp_path, monkeypatch, {
"SH01_shape_ml__ETH__1h": {"real_in_position": True, "real_amount": 0.053,
"real_side": "sell"}})
assert ra.compute_stale_real_positions(max_age_min=15) == []
def test_flat_stale_worker_not_flagged(tmp_path, monkeypatch):
# worker vecchio MA flat: niente posizione reale -> non e' un orfano
ra = _setup(tmp_path, monkeypatch, {
"MR01_bollinger_fade__BTC__1h": {"real_in_position": False, "real_amount": 0.0}})
_age(ra.PAPER, "MR01_bollinger_fade__BTC__1h", 800)
assert ra.compute_stale_real_positions(max_age_min=15) == []
@@ -0,0 +1,72 @@
import numpy as np
import pandas as pd
from src.live.rotation_worker import RotationWorker
def _df(n=200, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
w.tick(data)
assert w.weights["AAA"] > 0
assert abs(sum(w.weights.values()) - 0.45) < 1e-9
def test_rotation_flat_when_risk_off(tmp_path):
# BTC in downtrend -> risk_off -> nessuna posizione
w = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path)
data = {"BTC": _df(slope=-1.0), "AAA": _df(slope=3.0)}
w.tick(data)
assert sum(w.weights.values()) == 0.0
def test_rotation_persists_and_resumes(tmp_path):
w = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path)
w.tick({"BTC": _df(slope=1.0), "AAA": _df(slope=3.0)})
w2 = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path)
assert w2.weights == w.weights
def test_rotation_warns_once_on_short_panel(tmp_path, monkeypatch):
# worker GIA' operativo + panel troncato -> WARN (una sola notifica per episodio)
calls = []
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
lambda e, d=None: calls.append(e))
w = RotationWorker(universe=["BTC", "AAA"], data_dir=tmp_path)
w.last_bar_ts = 123 # era gia' operativo
short = {"BTC": _df(n=20), "AAA": _df(n=20)}
w.tick(short)
w.tick(short) # dedup: niente seconda notifica
assert calls == ["PANEL_SHORT"]
w.tick({"BTC": _df(n=200), "AAA": _df(n=200)}) # recovery resetta il dedup
assert w._panel_warned is False
def test_panel_drops_forming_daily_bar(tmp_path):
# serie 1d che termina ADESSO -> l'ultima riga e' la candela 1d in formazione
import time
from src.live.rotation_worker import _panel
n = 200
now = int(time.time() * 1000)
ts = now - 86_400_000 * np.arange(n - 1, -1, -1)
def mk(slope):
c = np.linspace(100, 100 + slope * n, n)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c,
"close": c, "volume": 1.0})
panel, cols = _panel({"BTC": mk(1.0), "AAA": mk(2.0)}, ["BTC", "AAA"])
assert len(panel) == n - 1 # barra in formazione scartata
assert int(panel["timestamp"].iloc[-1]) == int(ts[-2])
def test_rotation_no_warn_on_cold_start(tmp_path, monkeypatch):
calls = []
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
lambda e, d=None: calls.append(e))
w = RotationWorker(universe=["BTC", "AAA"], data_dir=tmp_path) # last_bar_ts=0
w.tick({"BTC": _df(n=20), "AAA": _df(n=20)})
assert calls == []
+52
View File
@@ -0,0 +1,52 @@
from src.portfolio.runner import build_worker_for, pos_for_spec
from src.portfolio.base import SleeveSpec
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
def test_build_single_worker_capital_from_alloc(tmp_path):
spec = SleeveSpec(kind="single", name="MR01", sid="MR01_BTC", asset="BTC",
params={"bb_window": 50, "k": 2.5, "sl_atr": 2.0, "max_bars": 24})
w = build_worker_for(spec, alloc_capital=300.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, StrategyWorker)
assert w.capital == 300.0 and w.leverage == 2.0
def test_build_pairs_worker(tmp_path):
spec = SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC",
params={"n": 50, "z_in": 2.0, "z_exit": 0.75, "max_bars": 72})
w = build_worker_for(spec, alloc_capital=200.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, PairsWorker)
assert w.capital == 200.0
def test_pos_for_spec_family_override():
"""position_size per-famiglia (improvement-sweep punto 8): l'override PAIRS si
applica ai soli PR_*, gli altri sleeve restano sul globale."""
fam = {"PAIRS": 0.20}
assert pos_for_spec("PR_ETHBTC", 0.5, fam) == 0.20
assert pos_for_spec("PR_ADAETH", 0.5, fam) == 0.20
assert pos_for_spec("MR01_BTC", 0.5, fam) == 0.5 # FADE -> globale
assert pos_for_spec("SH_BTC", 0.5, fam) == 0.5 # SHAPE -> globale
assert pos_for_spec("TSM01", 0.5, fam) == 0.5
assert pos_for_spec("PR_ETHBTC", 0.5, {}) == 0.5 # nessun override
def test_build_pairs_worker_position_size(tmp_path):
spec = SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC",
params={"n": 50, "z_in": 2.0, "z_exit": 0.75, "max_bars": 72})
w = build_worker_for(spec, alloc_capital=200.0, leverage=2.0,
position_size=0.20, data_dir=tmp_path)
assert w.position_size == 0.20
def test_build_ml_sh01_is_plain_strategyworker(tmp_path):
"""SH01 (kind=ml) deve costruirsi come StrategyWorker che esegue SH01_shape_ml — NON il
vecchio MLWorkerWrapper (SignalEngine squeeze scartato, che ignorava la strategia ed usciva
a hold_bars=3). Regressione del bug di wiring scoperto il 2026-06-01."""
spec = SleeveSpec(kind="ml", name="SH01", sid="SH_BTC", asset="BTC")
w = build_worker_for(spec, alloc_capital=58.82, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, StrategyWorker)
assert w.strategy.name == "SH01_shape_ml" # esegue davvero shape-ML
assert not hasattr(w, "engine") # niente SignalEngine squeeze
assert w.tf == "1h"
+39
View File
@@ -0,0 +1,39 @@
"""T5: integrazione worker honest/TSM01 nel PortfolioRunner."""
from src.portfolio.runner import build_worker_for, _STRAT_MODULE, _MULTI_KINDS
from src.portfolio.base import SleeveSpec
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.strategy_worker import StrategyWorker
from scripts.portfolios._defs import PORTFOLIOS
def test_build_basket_worker(tmp_path):
spec = SleeveSpec(kind="basket", name="TR01", sid="TR01_basket",
params={"universe": ["BNB", "BTC"], "tf": "4h"})
w = build_worker_for(spec, alloc_capital=120.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, BasketTrendWorker) and w.capital == 120.0
def test_build_rotation_and_tsmom(tmp_path):
rot = SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot",
params={"universe": ["BTC", "ETH"], "tf": "1d", "top_k": 1, "gross": 0.45})
tsm = SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01",
params={"universe": ["BTC", "ETH"], "tf": "1d", "gross": 0.30})
wr = build_worker_for(rot, 100.0, 2.0, data_dir=tmp_path)
wt = build_worker_for(tsm, 100.0, 2.0, data_dir=tmp_path)
assert isinstance(wr, RotationWorker) and wr.capital == 100.0
assert isinstance(wt, TsmomWorker) and wt.capital == 100.0
def test_dip01_builds_as_strategy_worker(tmp_path):
spec = SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC")
w = build_worker_for(spec, 80.0, 2.0, data_dir=tmp_path)
assert isinstance(w, StrategyWorker) and w.capital == 80.0
def test_port06_has_no_unsupported_sleeves():
p = PORTFOLIOS["PORT06"]
unsupported = [s.sid for s in p.sleeves
if not (s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE)]
assert unsupported == []
@@ -0,0 +1,33 @@
from src.portfolio.runner import rebalance_allocations
from src.portfolio.ledger import PortfolioLedger
class FakeWorker:
def __init__(self, cap, in_position=False):
self.capital = cap
self.in_position = in_position
def test_rebalance_resizes_flat_workers(tmp_path):
L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path)
workers = {"a": FakeWorker(700.0), "b": FakeWorker(500.0)} # equity 1200, both flat
rebalance_allocations(L, workers, {"a": 0.5, "b": 0.5})
assert L.total_capital == 1200.0
assert workers["a"].capital == 600.0 and workers["b"].capital == 600.0
def test_rebalance_in_position_conserves_equity(tmp_path):
# FIX 2026-06-13: il worker in posizione trattiene il suo capitale (deployato);
# i flat si dividono il RESTO per peso rinormalizzato -> Σcapital CONSERVATO.
# Prima (bug): i flat prendevano peso×total includendo il capitale dell'in-pos
# -> somma gonfiata (qui sarebbe stata 1300, +100 fantasma).
L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path)
workers = {"a": FakeWorker(700.0, in_position=True),
"b": FakeWorker(300.0), "c": FakeWorker(200.0)} # equity 1200
rebalance_allocations(L, workers, {"a": 0.5, "b": 0.25, "c": 0.25})
assert L.total_capital == 1200.0
assert workers["a"].capital == 700.0 # invariato: posizione aperta (reserved)
# b e c si dividono 1200-700=500 per peso 0.25/0.25 rinormalizzato -> 250 ciascuno
assert workers["b"].capital == 250.0 and workers["c"].capital == 250.0
total = sum(w.capital for w in workers.values())
assert total == 1200.0 # equity CONSERVATA dal ribilancio
@@ -0,0 +1,60 @@
"""SH01 path live: last_block_only == tail del walk-forward completo (parity by
construction) + merge storia parquet/feed del runner."""
import numpy as np
import pandas as pd
import pytest
from src.portfolio.runner import _with_history
def _df(n=6500, seed=0, t0=1_600_000_000_000):
rng = np.random.default_rng(seed)
c = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
h = c * (1 + np.abs(rng.normal(0, 0.004, n)))
l = c * (1 - np.abs(rng.normal(0, 0.004, n)))
o = np.roll(c, 1); o[0] = c[0]
ts = t0 + 3_600_000 * np.arange(n)
return pd.DataFrame({"timestamp": ts, "open": o, "high": h, "low": l,
"close": c, "volume": 1.0})
def test_last_block_only_matches_full_wf_tail():
from scripts.analysis.shape_ml_research import ml_wf_entries
df = _df()
cfg = dict(W=24, H=12, model="logit", thresh=0.55, train_min=4000, retrain=500)
full = ml_wf_entries(df, **cfg)
last = ml_wf_entries(df, last_block_only=True, **cfg)
n = len(df)
# confine dell'ultimo blocco: start + k*retrain (deterministico)
start = max(cfg["train_min"], cfg["W"] - 1)
B = start
while B + cfg["retrain"] < n - 1:
B += cfg["retrain"]
full_tail = [e for e in full if e["i"] >= B]
assert last == full_tail # identita' esatta, non solo simile
assert all(e["i"] >= B for e in last)
def test_with_history_merges_contiguous():
hist = _df(n=100)
live = _df(n=50, t0=int(hist["timestamp"].iloc[60])) # overlap: live parte dentro hist
out = _with_history(hist, live)
assert len(out) == 60 + 50 # hist pre-overlap + live completo
ts = out["timestamp"].values
assert (np.diff(ts) > 0).all() # serie strettamente crescente
def test_with_history_gap_falls_back_to_live():
hist = _df(n=100)
gap_start = int(hist["timestamp"].iloc[-1]) + 10 * 3_600_000 # buco di 10 barre
live = _df(n=50, t0=gap_start)
warned = set()
out = _with_history(hist, live, warned, "BTC")
assert len(out) == 50 and "BTC" in warned # solo feed + warn deduplicato
out2 = _with_history(hist, live, warned, "BTC")
assert len(out2) == 50 # secondo giro: nessun nuovo warn
def test_with_history_none_hist_passthrough():
live = _df(n=50)
assert _with_history(None, live) is live
@@ -0,0 +1,79 @@
"""Orfani single-leg (2026-06-12): un close fallito/cappato registra la quota
residua in orphan_legs (parita' coi pairs) cosi' il reconciler la conta come
drift SPIEGATO — prima il REAL_CLOSE_PARTIAL di MR07 (0.102 ETH nel lock testnet)
lasciava drift non spiegato. Nessuna rete: executor finto."""
import json
from types import SimpleNamespace
from src.live.execution import Fill
from src.live.strategy_worker import StrategyWorker
from src.strategies.base import Signal
from src.live import books
class FailingCloseExec:
"""Apre ok, chiude con fill ZERO (venue locked / netting negato)."""
verify_polls = 1
verify_sleep = 0.0
disaster_sl_pct = None
def open(self, instrument, side, notional, label=None):
amt = round(notional / 100.0, 6)
return Fill(instrument, side, notional, amt, 100.0, 0.0, 0.05,
"oid-open", "filled", True, filled_amount=amt)
def place_tp_limit(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def cancel_order(self, oid):
return {"state": "cancelled"}
def resting_fills(self, instrument, oid):
return 0.0, None, 0.0
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell", 0.0, amount, None, 0.0, 0.0,
None, "error", False, notes="place_order error: locked_by_admin",
filled_amount=0.0)
def _worker(tmp_path):
return StrategyWorker(
strategy=SimpleNamespace(name="MR07_return_reversal", fee_rt=0.001),
asset="ETH", tf="1h", capital=100.0, position_size=0.5, leverage=2.0,
data_dir=tmp_path, executor=FailingCloseExec(),
exec_instrument="ETH_USDC-PERPETUAL")
def test_failed_close_registers_orphan(tmp_path, monkeypatch):
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
w = _worker(tmp_path)
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
metadata={"max_bars": 12}), 100.0)
amt = w.real_amount
assert w.real_in_position and amt > 0
w._close_position(105.0, "time_limit")
assert not w.real_in_position
assert len(w.orphan_legs) == 1
o = w.orphan_legs[0]
assert o["instrument"] == "ETH_USDC-PERPETUAL"
assert o["entry_side"] == "buy" and abs(o["amount"] - amt) < 1e-9
# persistito nello status (resume-safe) e visto da books.real_books
st = json.loads((tmp_path / w.worker_id / "status.json").read_text())
assert st["orphan_legs"] == w.orphan_legs
monkeypatch.setattr(books, "PAPER", tmp_path)
_, orphans = books.real_books()
assert abs(orphans["ETH_USDC-PERPETUAL"] - amt) < 1e-9 # buy = firmato +
def test_clean_close_no_orphan(tmp_path, monkeypatch):
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
w = _worker(tmp_path)
w.executor.close_amount = lambda instrument, side, amount, label=None: Fill(
instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
"filled", True, filled_amount=amount)
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
metadata={"max_bars": 12}), 100.0)
w._close_position(105.0, "time_limit")
assert w.orphan_legs == []
+21
View File
@@ -0,0 +1,21 @@
import pandas as pd
from src.portfolio import sleeves as S
ALL_IDS = {"MR01_BTC", "MR02_BTC", "MR07_BTC", "MR01_ETH", "MR02_ETH", "MR07_ETH",
"DIP01_BTC", "TR01_basket", "ROT02_rot",
"PR_ETHBTC", "PR_LTCETH", "PR_ADAETH", "PR_BTCLTC", "PR_ETHSOL",
"TSM01", "SH_BTC", "SH_ETH"}
def test_all_sleeve_equities_keys_and_index():
eq = S.all_sleeve_equities()
assert ALL_IDS <= set(eq)
s = eq["MR01_BTC"]
assert isinstance(s, pd.Series) and len(s) > 100
assert str(s.index.tz) == "UTC"
def test_returns_df_aligned():
df = S.sleeve_returns_df(["MR01_BTC", "PR_ETHBTC", "SH_BTC"])
assert list(df.columns) == ["MR01_BTC", "PR_ETHBTC", "SH_BTC"]
assert df.isna().sum().sum() == 0
+58
View File
@@ -0,0 +1,58 @@
"""STALE_FEED (2026-06-05): alert quando il feed e' flat da >= 2 barre 1h complete,
e al risveglio col gap %. Solo osservabilita': nessun effetto sui worker."""
import pandas as pd
from src.portfolio.runner import _check_stale_feed
def _df(closes_completed, forming=None, price0=100.0):
"""Serie 1h: barre complete con i close dati (flat = ripete il precedente
con O=H=L=C), piu' eventuale candela in corso (timestamp = ora corrente)."""
end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000)
vals = list(closes_completed) + ([forming] if forming is not None else [])
n = len(vals)
rows = []
for j, v in enumerate(vals):
ts = end_ms - (n - 1 - j) * 3_600_000
rows.append({"timestamp": ts, "open": v, "high": v, "low": v, "close": v, "volume": 0.0})
# rendi NON-flat le barre che cambiano prezzo rispetto alla precedente
for j in range(1, n):
if rows[j]["close"] != rows[j - 1]["close"]:
rows[j]["high"] = rows[j]["close"] * 1.001
return pd.DataFrame(rows)
def _events(monkeypatch):
sent = []
import src.live.telegram_notifier as tn
monkeypatch.setattr(tn, "notify_event", lambda ev, data=None: sent.append((ev, data)))
return sent
def test_alert_after_two_flat_complete_bars(monkeypatch):
sent = _events(monkeypatch)
alerted = set()
closes = [100 + i for i in range(10)] + [110.0, 110.0, 110.0] # 3 flat complete
_check_stale_feed("ETH", _df(closes, forming=110.0), alerted)
assert "ETH" in alerted
assert sent and sent[0][0] == "STALE_FEED" and sent[0][1]["flat_bars_1h"] >= 2
def test_no_alert_on_live_feed(monkeypatch):
sent = _events(monkeypatch)
alerted = set()
closes = [100 + i for i in range(13)] # sempre in movimento
_check_stale_feed("ETH", _df(closes, forming=113.5), alerted)
assert not alerted and not sent
def test_recovery_notifies_gap_once(monkeypatch):
sent = _events(monkeypatch)
alerted = {"ETH"} # episodio in corso
closes = [100.0] * 10 + [96.6] # risveglio: gap -3.4%
_check_stale_feed("ETH", _df(closes, forming=96.5), alerted)
assert "ETH" not in alerted
assert sent and sent[-1][1]["status"] == "RIPRESO" and abs(sent[-1][1]["gap_pct"] + 3.4) < 0.1
# secondo poll: nessun doppio alert
sent.clear()
_check_stale_feed("ETH", _df(closes, forming=96.5), alerted)
assert not sent
+105
View File
@@ -0,0 +1,105 @@
"""TP_PHANTOM (2026-06-11): il tocco TP che esiste solo nel feed (spike-print testnet)
NON deve chiudere — il LIMIT resting sul book reale e' l'oracolo: zero fill + prezzo
lontano dal livello = wick fantasma -> exit soppressa. Con fill reale (anche parziale),
o prezzo realmente oltre il livello, o worker senza esecuzione -> comportamento storico."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(last_high, last_low, n=120, price=100.0):
c = [price] * n
h = [price] * n
l = [price] * n
h[-1] = last_high
l[-1] = last_low
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": h, "low": l, "close": c, "volume": 1.0})
class _FakeExecutor:
"""Solo cio' che serve al gate + alla _real_close di un eventuale exit."""
def __init__(self, tp_filled_amount=0.0):
self.tp_filled_amount = tp_filled_amount
self.verify_polls = 1
self.verify_sleep = 0.0
def resting_fills(self, instrument, order_id):
return self.tp_filled_amount, 102.0 if self.tp_filled_amount else None, 0.0
def cancel_order(self, order_id):
return {"state": "cancelled"}
def close_amount(self, instrument, side, amount, label=""):
class F:
verified = True
amount = 0.01
filled_amount = 0.01
fill_price = 100.0
fee_usd = 0.01
order_id = "X"
return F()
def _long_worker(tmp, executor=None):
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp,
executor=executor,
exec_instrument="BTC_USDC-PERPETUAL" if executor else None)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 102.0
w.sl = 98.0
w.max_bars = 24
w.bars_held = 1
w.last_bar_ts = 0
if executor:
w.real_in_position = True
w.real_side = "buy"
w.real_amount = 0.01
w.real_entry_price = 100.0
w.real_entry_notional = 1.0
w.real_tp_order_id = "TP-1"
return w
def test_phantom_touch_suppressed(tmp_path):
# wick a 102.5 nel feed, resting zero-fill, close 100 lontano dal TP -> NON chiude
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
w.tick(_df(last_high=102.5, last_low=99.5))
assert w.in_position
assert w.real_in_position
def test_real_fill_closes(tmp_path):
# stesso wick ma il resting HA fillato -> tocco reale -> chiude
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.01))
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
def test_price_beyond_tp_closes_without_fill(tmp_path):
# close corrente OLTRE il TP (gap fra poll): tocco genuino anche a zero fill
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
df = _df(last_high=103.0, last_low=99.5)
df.loc[df.index[-1], "close"] = 102.6
w.tick(df)
assert not w.in_position
def test_no_executor_keeps_legacy_behavior(tmp_path):
# worker senza esecuzione reale: il gate non si applica (parita' storica)
w = _long_worker(tmp_path, executor=None)
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
def test_phantom_does_not_block_other_exits(tmp_path):
# tocco TP fantasma E max_bars raggiunto nello stesso tick -> esce a time_limit
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
w.bars_held = 24
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
+44
View File
@@ -0,0 +1,44 @@
import numpy as np
import pandas as pd
from src.live.tsmom_worker import TsmomWorker
def _df(n=300, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_tsmom_selects_full_consensus_uptrend(tmp_path):
# tutti gli orizzonti positivi -> score=1>=thr; BTC su -> risk_on
w = TsmomWorker(universe=["BTC", "AAA"], horizons=(63, 126, 252), thr=1.0,
gross=0.30, data_dir=tmp_path)
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=2.0)}
w.tick(data)
assert w.weights["BTC"] > 0 and w.weights["AAA"] > 0
assert abs(sum(w.weights.values()) - 0.30) < 1e-9
def test_tsmom_flat_when_risk_off(tmp_path):
w = TsmomWorker(universe=["BTC", "AAA"], thr=1.0, gross=0.30, data_dir=tmp_path)
data = {"BTC": _df(slope=-1.0), "AAA": _df(slope=2.0)}
w.tick(data)
assert sum(w.weights.values()) == 0.0
def test_tsmom_persists_and_resumes(tmp_path):
w = TsmomWorker(universe=["BTC", "AAA"], gross=0.30, data_dir=tmp_path)
w.tick({"BTC": _df(slope=1.0), "AAA": _df(slope=2.0)})
w2 = TsmomWorker(universe=["BTC", "AAA"], gross=0.30, data_dir=tmp_path)
assert w2.weights == w.weights
def test_tsmom_warns_on_short_panel(tmp_path, monkeypatch):
# worker GIA' operativo + panel sotto need=253 -> WARN invece di silent return
calls = []
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
lambda e, d=None: calls.append(e))
w = TsmomWorker(universe=["BTC", "AAA"], gross=0.30, data_dir=tmp_path)
w.last_bar_ts = 123
w.tick({"BTC": _df(n=100), "AAA": _df(n=100)})
assert calls == ["PANEL_SHORT"] and w._panel_warned
@@ -0,0 +1,86 @@
"""Circuit-breaker venue-lock (2026-06-12): dopo lock_trip reject 'locked'
consecutivi le APERTURE sono sospese senza toccare l'API (i worker seguono il
path REAL_OPEN_FAIL/sim_fallback); le chiusure si tentano sempre; il probe a
fine cooldown riarma o resetta. Nessuna rete: CerberoClient stubbato."""
import time
from src.live.execution import ExecutionClient
class _Venue:
"""Stub CerberoClient: locked finche' .locked=True, poi ordini ok."""
def __init__(self):
self.locked = True
self.place_calls = 0
def place_order(self, instrument, side, amount, order_type="market",
price=None, label=None, reduce_only=False):
self.place_calls += 1
if self.locked:
return {"state": "error", "error": "locked_by_admin"}
return {"order": {"order_id": "o1", "order_state": "filled",
"filled_amount": amount, "average_price": 100.0},
"trades": [{"amount": amount, "price": 100.0, "fee": 0.0}]}
def get_ticker(self, instrument):
return {"mark_price": 100.0}
def get_trade_history(self, limit=50, instrument_name=None):
return []
def _ec(monkeypatch, sent):
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
lambda ev, data: sent.append((ev, data)))
venue = _Venue()
ec = ExecutionClient(client=venue)
return ec, venue
INST = "ETH_USDC-PERPETUAL"
def test_breaker_trips_and_blocks_opens(monkeypatch):
sent = []
ec, venue = _ec(monkeypatch, sent)
for _ in range(ec.lock_trip):
f = ec.open(INST, "buy", 50.0)
assert not f.verified
assert venue.place_calls == ec.lock_trip and ec.lock_blocked()
assert [e for e, _ in sent] == ["VENUE_LOCK"] # un alert al trip
# aperture sospese: NESSUNA chiamata API, nota dedicata
f = ec.open(INST, "buy", 50.0)
assert venue.place_calls == ec.lock_trip
assert "venue_lock_breaker" in f.notes
assert sent == sent[:1] # niente spam
def test_closes_still_attempted_and_refresh_cooldown(monkeypatch):
sent = []
ec, venue = _ec(monkeypatch, sent)
for _ in range(ec.lock_trip):
ec.open(INST, "buy", 50.0)
ec._net_close_allowance = lambda *a, **k: 0.0 # niente fallback netting
before = ec._lock_until
time.sleep(0.01)
f = ec.close_amount(INST, "buy", 0.5, label="w1") # la chiusura PASSA dall'API
assert venue.place_calls == ec.lock_trip + 1
assert not f.verified
assert ec._lock_until > before # locked dal close -> cooldown esteso
def test_probe_rearms_or_resets(monkeypatch):
sent = []
ec, venue = _ec(monkeypatch, sent)
for _ in range(ec.lock_trip):
ec.open(INST, "buy", 50.0)
# cooldown scaduto + venue ancora locked -> probe fallisce e riscatta
ec._lock_until = time.monotonic() - 1
ec.open(INST, "buy", 50.0)
assert venue.place_calls == ec.lock_trip + 1 and ec.lock_blocked()
# venue sbloccato -> il probe passa, breaker resettato, alert di rientro
ec._lock_until = time.monotonic() - 1
venue.locked = False
f = ec.open(INST, "buy", 50.0)
assert f.verified and not ec.lock_blocked() and ec._lock_streak == 0
assert sent[-1][0] == "VENUE_LOCK" and sent[-1][1].get("status") == "RIENTRATO"
+53
View File
@@ -0,0 +1,53 @@
import numpy as np
import pandas as pd
import pytest
from src.portfolio import weighting as W
def test_family_of():
assert W.family_of("PR_ETHBTC") == "PAIRS"
assert W.family_of("SH_BTC") == "SHAPE"
assert W.family_of("TSM01") == "TSM"
assert W.family_of("MR01_BTC") == "FADE"
assert W.family_of("DIP01_BTC") == "HONEST"
def test_equal_sums_to_one():
w = W.equal(["a", "b", "c", "d"])
assert pytest.approx(sum(w.values())) == 1.0
assert all(abs(v - 0.25) < 1e-9 for v in w.values())
def test_manual_normalizes():
w = W.manual(["a", "b"], {"a": 3, "b": 1})
assert pytest.approx(w["a"]) == 0.75 and pytest.approx(w["b"]) == 0.25
def test_cap_limits_family_and_redistributes():
ids = ["PR_ETHBTC", "PR_LTCETH", "MR01_BTC", "MR02_BTC"]
w = W.cap(ids, caps={"PAIRS": 0.30})
pairs_w = w["PR_ETHBTC"] + w["PR_LTCETH"]
assert pytest.approx(pairs_w, abs=1e-9) == 0.30
assert pytest.approx(sum(w.values())) == 1.0
assert w["MR01_BTC"] > 0.25
def test_inverse_vol_prefers_low_vol():
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
rng = np.random.default_rng(0)
df = pd.DataFrame({"lo": rng.normal(0, 0.01, 100), "hi": rng.normal(0, 0.05, 100)}, index=idx)
w = W.inverse_vol(["lo", "hi"], df, lookback=90)
assert w["lo"] > w["hi"]
assert pytest.approx(sum(w.values())) == 1.0
def test_cluster_rp_equal_across_clusters():
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
rng = np.random.default_rng(1)
cols = ["MR01_BTC", "MR02_BTC", "PR_ETHBTC"]
df = pd.DataFrame({c: rng.normal(0, 0.02, 100) for c in cols}, index=idx)
clusters = {"MR01_BTC": "BTC-rev", "MR02_BTC": "BTC-rev", "PR_ETHBTC": "ETH-rev"}
w = W.cluster_rp(cols, clusters, df, lookback=90)
assert pytest.approx(sum(w.values())) == 1.0
# due cluster equipesati: il cluster con 1 solo sleeve (ETH-rev) prende ~0.5
assert pytest.approx(w["PR_ETHBTC"], abs=1e-9) == 0.5
@@ -0,0 +1,53 @@
"""Fix metrica `win`: una trade è win solo se il PnL è positivo NETTO delle fee, non sul
lordo. Prima del fix `is_win = trade_return > 0` (lordo): un take-profit colpito con un
movimento più piccolo del round-trip fee veniva contato come win pur perdendo denaro,
gonfiando l'accuracy (es. MR01/DIP01 BTC in regime piatto: TP entro le fee)."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _worker(tmp, fee_rt=0.001):
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
w.fee_rt = fee_rt
w.in_position = True
w.direction = 1
w.entry_price = 100.0
return w
def _last_close(w):
import json
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
return [r for r in rows if r.get("event") == "CLOSE"][-1]
def test_tiny_favorable_move_is_loss_after_fees(tmp_path):
"""Mossa lorda +0,05% < fee RT 0,10%: prezzo salito, ma netto negativo -> NON è win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(100.05, "take_profit") # +0.05% lordo, sotto le fee
c = _last_close(w)
assert c["net_return"] < 0
assert c["win"] is False # prima del fix era True
assert w.total_wins == 0
def test_move_beyond_fees_is_win(tmp_path):
"""Mossa lorda +0,30% > fee 0,10%: netto positivo -> win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(100.30, "take_profit")
c = _last_close(w)
assert c["net_return"] > 0
assert c["win"] is True
assert w.total_wins == 1
def test_loss_is_not_win(tmp_path):
"""Prezzo sceso su long: perdita netta -> non win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(99.0, "stop_loss")
c = _last_close(w)
assert c["win"] is False
assert w.total_wins == 0