From d3662f60982326f98252d18954f2ae7904388bda Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 10 May 2026 23:54:46 +0200 Subject: [PATCH] feat(adversarial): time_in_market_too_high HIGH (>80% always-in-market) Simmetrico opposto di flat_too_long: penalizza strategie LONG/SHORT su piu' dell'80% delle bar. Una sempre-in-market e' leveraged B&H camuffato, esposto a funding cumulato (perp ogni 8h), tail risk eventi notturni e nessuna opportunity-cost flexibility. Sweet spot fitness positiva: 5-80% time in market. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/agents/adversarial.py | 34 ++++++++-- tests/unit/test_adversarial.py | 94 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/multi_swarm/agents/adversarial.py b/src/multi_swarm/agents/adversarial.py index 78d2cbf..68f0512 100644 --- a/src/multi_swarm/agents/adversarial.py +++ b/src/multi_swarm/agents/adversarial.py @@ -1,6 +1,7 @@ """Adversarial agent: ispeziona una :class:`Strategy` con check euristici hand-crafted per scovare patologie note (degenerate, no-trade, over/under -trading, flat-too-long, fees-eat-alpha) prima del training vero e proprio. +trading, flat-too-long, time-in-market-too-high, fees-eat-alpha) prima +del training vero e proprio. Pipeline: @@ -11,10 +12,13 @@ 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. +e undertrading (HIGH se n_trades < 10), piu' tre nuovi check HIGH: +``flat_too_long`` (signal flat >95% delle bar), +``time_in_market_too_high`` (signal long/short >80% delle bar, di fatto +leveraged buy-and-hold con funding/tail-risk cumulato) e +``fees_eat_alpha`` (fees > 50% del gross_pnl positivo). Killano le +strategie "lucky shot", le sempre-in-market e quelle con margine sottile +non sostenibile in produzione. """ from __future__ import annotations @@ -133,6 +137,26 @@ class AdversarialAgent: ) ) + # Time-in-market-too-high: signal LONG o SHORT >80% delle bar. + # Simmetrico opposto di flat_too_long: una strategia sempre-in-market + # e' di fatto leveraged buy-and-hold, esposta a funding cumulato su + # perp (paid ogni 8h), tail risk eventi notturni/weekend, nessuna + # opportunity-cost flexibility. Sweet spot fitness positiva: 5-80% + # time in market (combinato con flat_too_long). + active_ratio = n_active / n_bars if n_bars > 0 else 0.0 + if active_ratio > 0.80: + report.findings.append( + Finding( + name="time_in_market_too_high", + severity=Severity.HIGH, + detail=( + f"Signal long/short for {active_ratio * 100:.1f}% of bars " + "(>80% threshold); esposizione cumulativa funding + tail risk, " + "di fatto leveraged B&H" + ), + ) + ) + # 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. diff --git a/tests/unit/test_adversarial.py b/tests/unit/test_adversarial.py index d81fe46..9de9997 100644 --- a/tests/unit/test_adversarial.py +++ b/tests/unit/test_adversarial.py @@ -338,3 +338,97 @@ def test_fees_eat_alpha_flagged(monkeypatch: pytest.MonkeyPatch, f.name == "fees_eat_alpha" and f.severity == Severity.HIGH for f in report.findings ) + + +def test_time_in_market_too_high_flagged(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """Signal LONG per >80% delle bar -> HIGH time_in_market_too_high.""" + n_bars = len(ohlcv) + # 90% LONG, 10% FLAT iniziali (warmup-like) per evitare degenerate. + n_flat = int(n_bars * 0.10) + sig_values = [Side.FLAT] * n_flat + [Side.LONG] * (n_bars - n_flat) + 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 == "time_in_market_too_high" and f.severity == Severity.HIGH + for f in report.findings + ) + + +def test_reasonable_balanced_strategy_not_flagged(monkeypatch: pytest.MonkeyPatch, + ohlcv: pd.DataFrame) -> None: + """Mix ~50% flat, ~25% long, ~25% short: no HIGH sui gate temporali.""" + n_bars = len(ohlcv) + # Pattern ciclico: 2 flat, 1 long, 1 short per ogni gruppo da 4 bar. + # Risultato: ~50% FLAT, ~25% LONG, ~25% SHORT. flat_ratio=0.5 < 0.95, + # active_ratio=0.5 < 0.80. + pattern = [Side.FLAT, Side.FLAT, Side.LONG, Side.SHORT] + sig_values = [pattern[i % 4] for i in range(n_bars)] + 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) + # I due gate temporali non devono triggerare. + names = [f.name for f in report.findings] + assert "flat_too_long" not in names + assert "time_in_market_too_high" not in names