chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user