feat(adversarial): phase 1.5 hardening (tighter thresholds + flat_too_long + fees_eat_alpha)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""Adversarial agent: ispeziona una :class:`Strategy` con check euristici
|
"""Adversarial agent: ispeziona una :class:`Strategy` con check euristici
|
||||||
hand-crafted per scovare patologie note (degenerate, no-trade, over/under
|
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:
|
Pipeline:
|
||||||
|
|
||||||
@@ -9,6 +9,12 @@ Pipeline:
|
|||||||
Le euristiche sono volutamente coarse: l'agente non rimpiazza la
|
Le euristiche sono volutamente coarse: l'agente non rimpiazza la
|
||||||
falsificazione, ma sega presto i casi degeneri (es. ``gt close -1e9`` →
|
falsificazione, ma sega presto i casi degeneri (es. ``gt close -1e9`` →
|
||||||
sempre long) che inquinerebbero il leaderboard del swarm.
|
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
|
from __future__ import annotations
|
||||||
@@ -87,24 +93,61 @@ class AdversarialAgent:
|
|||||||
|
|
||||||
n_bars = len(ohlcv)
|
n_bars = len(ohlcv)
|
||||||
n_trades = len(result.trades)
|
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.
|
# che le fees mangiano qualunque edge.
|
||||||
if n_trades > n_bars / 5:
|
if n_trades > n_bars / 20:
|
||||||
report.findings.append(
|
report.findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
name="overtrading",
|
name="overtrading",
|
||||||
severity=Severity.MEDIUM,
|
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
|
# Undertrading: < 10 trade -> HIGH (Phase 1.5: era < 5 MEDIUM).
|
||||||
# distinguere edge da rumore (lucky shot).
|
# Sample size troppo piccolo per distinguere edge da rumore: e'
|
||||||
if n_trades < 5:
|
# un "lucky shot" non riproducibile out-of-sample.
|
||||||
|
if n_trades < 10:
|
||||||
report.findings.append(
|
report.findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
name="undertrading",
|
name="undertrading",
|
||||||
severity=Severity.MEDIUM,
|
severity=Severity.HIGH,
|
||||||
detail=f"only {n_trades} trades — likely lucky shot",
|
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}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
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
|
from multi_swarm.protocol.parser import parse_strategy
|
||||||
|
|
||||||
|
|
||||||
@@ -103,3 +109,232 @@ def test_zero_trade_strategy_flagged(ohlcv: pd.DataFrame) -> None:
|
|||||||
agent = AdversarialAgent()
|
agent = AdversarialAgent()
|
||||||
report = agent.review(ast, ohlcv)
|
report = agent.review(ast, ohlcv)
|
||||||
assert any(f.name == "no_trades" for f in report.findings)
|
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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user