From 56a631f38af68e50f50185b2459beb38ccc3a6ed Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 10 May 2026 23:36:35 +0200 Subject: [PATCH] feat(adversarial): phase 1.5 hardening (tighter thresholds + flat_too_long + fees_eat_alpha) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stringe le soglie esistenti e aggiunge due check HIGH per killare le strategie degeneri scoperte nel run v5 (top-1 +2.66% vs BTC B&H +106%, flat 99.8% del tempo, fees 69% del lordo). - overtrading: soglia da n_bars/5 a n_bars/20 (MEDIUM) - undertrading: HIGH se n_trades < 10 (era MEDIUM <5) — sample troppo piccolo per distinguere edge da rumore (lucky shot) - flat_too_long (NEW, HIGH): signal attivo per <5% delle bar — la strategia ha mancato il regime, e' una non-strategia - fees_eat_alpha (NEW, HIGH): gross_pnl > 0 ma fees > 50% del lordo — margine sottile non sostenibile in produzione Test count: 141 -> 145 (+4 nuovi test deterministici via monkeypatch). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/agents/adversarial.py | 61 ++++++- tests/unit/test_adversarial.py | 237 +++++++++++++++++++++++++- 2 files changed, 288 insertions(+), 10 deletions(-) diff --git a/src/multi_swarm/agents/adversarial.py b/src/multi_swarm/agents/adversarial.py index e545a8c..78d2cbf 100644 --- a/src/multi_swarm/agents/adversarial.py +++ b/src/multi_swarm/agents/adversarial.py @@ -1,6 +1,6 @@ """Adversarial agent: ispeziona una :class:`Strategy` con check euristici hand-crafted per scovare patologie note (degenerate, no-trade, over/under -trading) prima del training vero e proprio. +trading, flat-too-long, fees-eat-alpha) prima del training vero e proprio. Pipeline: @@ -9,6 +9,12 @@ Pipeline: Le euristiche sono volutamente coarse: l'agente non rimpiazza la falsificazione, ma sega presto i casi degeneri (es. ``gt close -1e9`` → sempre long) che inquinerebbero il leaderboard del swarm. + +Phase 1.5 hardening: soglie strette per overtrading (n_trades > n_bars/20) +e undertrading (HIGH se n_trades < 10), piu' due nuovi check HIGH: +``flat_too_long`` (signal flat >95% delle bar) e ``fees_eat_alpha`` +(fees > 50% del gross_pnl positivo). Killano le strategie "lucky shot" +e quelle con margine sottile non sostenibile in produzione. """ from __future__ import annotations @@ -87,24 +93,61 @@ class AdversarialAgent: n_bars = len(ohlcv) n_trades = len(result.trades) - # Overtrading: > 1 trade ogni 5 bar -> il segnale flippa cosi' spesso + # Overtrading: > 1 trade ogni 20 bar (Phase 1.5: era 1/5). + # Soglia stretta per scovare strategie che flippano cosi' spesso # che le fees mangiano qualunque edge. - if n_trades > n_bars / 5: + if n_trades > n_bars / 20: report.findings.append( Finding( name="overtrading", severity=Severity.MEDIUM, - detail=f"{n_trades} trades on {n_bars} bars (>1 per 5 bars)", + detail=f"{n_trades} trades on {n_bars} bars (>1 per 20 bars)", ) ) - # Undertrading: < 5 trade -> sample size troppo piccolo per - # distinguere edge da rumore (lucky shot). - if n_trades < 5: + # Undertrading: < 10 trade -> HIGH (Phase 1.5: era < 5 MEDIUM). + # Sample size troppo piccolo per distinguere edge da rumore: e' + # un "lucky shot" non riproducibile out-of-sample. + if n_trades < 10: report.findings.append( Finding( name="undertrading", - severity=Severity.MEDIUM, - detail=f"only {n_trades} trades — likely lucky shot", + severity=Severity.HIGH, + detail=f"only {n_trades} trades — likely lucky shot (<10 over training)", + ) + ) + + # Flat-too-long: signal attivo (LONG o SHORT) per <5% delle bar. + # Anche se la strategia produce trade, una che e' inerte 19h su 20 + # ha mancato il regime ed e' di fatto una non-strategia. + # NaN (warmup) contano come "flat" perche' downstream l'engine + # li riempie via ffill().fillna(Side.FLAT). + n_active = int(((signals == Side.LONG) | (signals == Side.SHORT)).sum()) + n_flat_or_nan = n_bars - n_active + flat_ratio = n_flat_or_nan / n_bars if n_bars > 0 else 1.0 + if flat_ratio > 0.95: + report.findings.append( + Finding( + name="flat_too_long", + severity=Severity.HIGH, + detail=f"Signal flat for {flat_ratio * 100:.1f}% of bars (>95% threshold)", + ) + ) + + # Fees-eat-alpha: gross_pnl > 0 ma fees > 50% del lordo. + # La strategia ha edge teorico ma il margine viene mangiato dai + # costi di transazione: non sostenibile in produzione. + # Se gross_pnl <= 0 il check non si applica (gia' perdente). + gross_pnl = sum(t.gross_pnl for t in result.trades) + total_fees = sum(t.fees for t in result.trades) + if gross_pnl > 0 and total_fees / gross_pnl > 0.5: + report.findings.append( + Finding( + name="fees_eat_alpha", + severity=Severity.HIGH, + detail=( + f"Fees ${total_fees:.2f} = " + f"{total_fees / gross_pnl * 100:.1f}% of gross ${gross_pnl:.2f}" + ), ) ) diff --git a/tests/unit/test_adversarial.py b/tests/unit/test_adversarial.py index 5d7591a..d81fe46 100644 --- a/tests/unit/test_adversarial.py +++ b/tests/unit/test_adversarial.py @@ -4,7 +4,13 @@ import numpy as np import pandas as pd import pytest -from multi_swarm.agents.adversarial import AdversarialAgent, AdversarialReport, Severity +from multi_swarm.agents.adversarial import ( + AdversarialAgent, + AdversarialReport, + Severity, +) +from multi_swarm.backtest.engine import BacktestResult +from multi_swarm.backtest.orders import Side, Trade from multi_swarm.protocol.parser import parse_strategy @@ -103,3 +109,232 @@ def test_zero_trade_strategy_flagged(ohlcv: pd.DataFrame) -> None: agent = AdversarialAgent() report = agent.review(ast, ohlcv) assert any(f.name == "no_trades" for f in report.findings) + + +# AST minimale valido (parser-acceptable). Usato nei test che monkeypatchano +# compile_strategy/BacktestEngine.run: il contenuto della strategia e' +# irrilevante perche' il signal/result viene iniettato. +_MINIMAL_STRATEGY_SRC = json.dumps( + { + "rules": [ + { + "condition": { + "op": "gt", + "args": [ + {"kind": "feature", "name": "close"}, + {"kind": "literal", "value": 0.0}, + ], + }, + "action": "entry-long", + } + ] + } +) + + +def _make_trade( + entry_ts: pd.Timestamp, + exit_ts: pd.Timestamp, + entry_price: float, + exit_price: float, + side: Side = Side.LONG, + fees_bp: float = 5.0, +) -> Trade: + return Trade( + entry_ts=entry_ts.to_pydatetime() if hasattr(entry_ts, "to_pydatetime") else entry_ts, + exit_ts=exit_ts.to_pydatetime() if hasattr(exit_ts, "to_pydatetime") else exit_ts, + side=side, + size=1.0, + entry_price=entry_price, + exit_price=exit_price, + fees_bp=fees_bp, + ) + + +def test_undertrading_under_10_is_high(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """5 trade su 500 bar -> HIGH undertrading (Phase 1.5: era MEDIUM <5).""" + fake_trades = [ + _make_trade( + ohlcv.index[i * 50], + ohlcv.index[i * 50 + 10], + entry_price=100.0, + exit_price=101.0, + ) + for i in range(5) + ] + fake_signals = pd.Series( + [Side.LONG] * 250 + [Side.FLAT] * 250, index=ohlcv.index, dtype=object + ) + + def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def] + return BacktestResult( + equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"), + returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"), + trades=fake_trades, + ) + + def fake_compile(strategy): # type: ignore[no-untyped-def] + return lambda df: fake_signals + + monkeypatch.setattr( + "multi_swarm.agents.adversarial.BacktestEngine.run", fake_run + ) + monkeypatch.setattr( + "multi_swarm.agents.adversarial.compile_strategy", fake_compile + ) + + src = _MINIMAL_STRATEGY_SRC + ast = parse_strategy(src) + agent = AdversarialAgent() + report = agent.review(ast, ohlcv) + assert any( + f.name == "undertrading" and f.severity == Severity.HIGH + for f in report.findings + ) + + +def test_overtrading_with_tighter_threshold(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """n_trades > n_bars/20 -> MEDIUM overtrading (Phase 1.5: era /5).""" + # 500 bar / 20 = 25. Forziamo 30 trade. + n = 30 + fake_trades = [ + _make_trade( + ohlcv.index[i * 10], + ohlcv.index[i * 10 + 5], + entry_price=100.0, + exit_price=100.5, + ) + for i in range(n) + ] + # Signal alternato per evitare flat_too_long: 50% LONG, 50% FLAT. + fake_signals = pd.Series( + [Side.LONG if i % 2 == 0 else Side.FLAT for i in range(len(ohlcv))], + index=ohlcv.index, + dtype=object, + ) + + def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def] + return BacktestResult( + equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"), + returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"), + trades=fake_trades, + ) + + def fake_compile(strategy): # type: ignore[no-untyped-def] + return lambda df: fake_signals + + monkeypatch.setattr( + "multi_swarm.agents.adversarial.BacktestEngine.run", fake_run + ) + monkeypatch.setattr( + "multi_swarm.agents.adversarial.compile_strategy", fake_compile + ) + + src = _MINIMAL_STRATEGY_SRC + ast = parse_strategy(src) + agent = AdversarialAgent() + report = agent.review(ast, ohlcv) + assert any( + f.name == "overtrading" and f.severity == Severity.MEDIUM + for f in report.findings + ) + + +def test_flat_too_long_flagged(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """Signal flat per >95% delle bar -> HIGH flat_too_long.""" + n_bars = len(ohlcv) + # 96% flat: 480 FLAT + 20 LONG = 96% flat ratio + n_active = 20 + sig_values = [Side.LONG] * n_active + [Side.FLAT] * (n_bars - n_active) + fake_signals = pd.Series(sig_values, index=ohlcv.index, dtype=object) + # 15 trade per evitare undertrading HIGH. + fake_trades = [ + _make_trade( + ohlcv.index[i * 30], + ohlcv.index[i * 30 + 1], + entry_price=100.0, + exit_price=101.0, + ) + for i in range(15) + ] + + def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def] + return BacktestResult( + equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"), + returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"), + trades=fake_trades, + ) + + def fake_compile(strategy): # type: ignore[no-untyped-def] + return lambda df: fake_signals + + monkeypatch.setattr( + "multi_swarm.agents.adversarial.BacktestEngine.run", fake_run + ) + monkeypatch.setattr( + "multi_swarm.agents.adversarial.compile_strategy", fake_compile + ) + + src = _MINIMAL_STRATEGY_SRC + ast = parse_strategy(src) + agent = AdversarialAgent() + report = agent.review(ast, ohlcv) + assert any( + f.name == "flat_too_long" and f.severity == Severity.HIGH + for f in report.findings + ) + + +def test_fees_eat_alpha_flagged(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """gross_pnl > 0 ma fees > 50% del lordo -> HIGH fees_eat_alpha.""" + # Costruisco trade con gross piccolo e fees alti via fees_bp esagerato. + # entry=100, exit=100.05, size=1 -> gross=0.05 + # fees_bp=200 (2%) su (100+100.05)*1*200/10000 = 4.001 fees per trade + # In aggregato: gross=15*0.05=0.75, fees=15*4.001=60 -> ratio enorme. + n = 15 + fake_trades = [ + _make_trade( + ohlcv.index[i * 30], + ohlcv.index[i * 30 + 1], + entry_price=100.0, + exit_price=100.05, + fees_bp=200.0, + ) + for i in range(n) + ] + # Signal misto per evitare flat_too_long. 50% attivo. + fake_signals = pd.Series( + [Side.LONG if i % 2 == 0 else Side.FLAT for i in range(len(ohlcv))], + index=ohlcv.index, + dtype=object, + ) + + def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def] + return BacktestResult( + equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"), + returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"), + trades=fake_trades, + ) + + def fake_compile(strategy): # type: ignore[no-untyped-def] + return lambda df: fake_signals + + monkeypatch.setattr( + "multi_swarm.agents.adversarial.BacktestEngine.run", fake_run + ) + monkeypatch.setattr( + "multi_swarm.agents.adversarial.compile_strategy", fake_compile + ) + + src = _MINIMAL_STRATEGY_SRC + ast = parse_strategy(src) + agent = AdversarialAgent() + report = agent.review(ast, ohlcv) + assert any( + f.name == "fees_eat_alpha" and f.severity == Severity.HIGH + for f in report.findings + )