diff --git a/src/multi_swarm_core/docs/decisions/2026-05-15-atr-pct-fix.md b/src/multi_swarm_core/docs/decisions/2026-05-15-atr-pct-fix.md new file mode 100644 index 0000000..9838496 --- /dev/null +++ b/src/multi_swarm_core/docs/decisions/2026-05-15-atr-pct-fix.md @@ -0,0 +1,82 @@ +# Decisione: indicatore `atr_pct` per fix bug protocollo unità + +**Data:** 2026-05-15 +**Status:** Implementato +**Scope:** `multi_swarm_core.protocol.{compiler,grammar,validator}` + `strategy_crypto/strategies/eth_*.json` + +## Contesto + +Phase 3 baseline-001 abortita il 2026-05-15 dopo 24h+ di paper-trading senza +trade. Analisi post-mortem: la strategia `eth_facd6af85d5d.json` aveva 2/4 +condizioni dead. Bug: + +- `atr(14) > 0.02` su ETH @ ~3000 USDT → ATR vale ~30-50 (unità prezzo + assoluto) → **sempre TRUE** → branch neutro +- `atr(14) < 0.01` → **sempre FALSE** → branch morto + +L'origine del bug è una **convention mismatch** fra LLM e compiler: +- Il LLM in Phase 1-2 ha generato literal frazionali (0.02, 0.01) + aspettandosi che `atr` fosse in % del prezzo (convenzione standard quant) +- Il compiler restituiva ATR in unità prezzo assoluto + +## Decisione + +**Aggiungere un indicatore separato `atr_pct(length) = atr/close`** che +restituisce ATR come frazione del prezzo close. NON modificare `atr` (che +resta in unità prezzo assoluto, usato in confronti relativi tipo +`atr > sma`). + +## Rationale + +- **Backcompat**: `atr` esistente continua a funzionare per le strategie + che lo usano in confronti relativi (es. `btc_fb63e851.json` ha + `atr > sma(14)`, entrambi unità prezzo → confronto valido). +- **Esplicito**: il nome `atr_pct` segnala chiaramente al lettore (umano + e LLM) la convenzione frazionale. +- **Diff minimo**: 4 file modificati (compiler, grammar, validator, JSON + ETH) + 2 test unitari nuovi. Nessun refactor del prompt LLM richiesto + in questa migrazione. +- **Replicabilità**: se in futuro emergono altri indicatori con problemi + analoghi (sma vs literal, macd vs literal), si applica lo stesso + pattern: aggiungere `_pct` come variante frazionale. + +## Alternative scartate + +- **Normalizzare `atr` (breaking change)**: avrebbe rotto le strategie + che lo usano in confronti relativi. Troppo rischioso post-Phase 1-2. +- **Cambiare il prompt LLM per generare literal in unità prezzo**: non + scalabile (literal dipende dall'asset corrente) + i 30 run GA già + fatti diventano invalidi. +- **Auto-detect** (literal piccolo → assume frazione): magic, frangile, + hard to debug. + +## Impatto + +- Strategie GA esistenti: nessun impatto su `btc_fb63e851.json`. Patchato + `eth_facd6af85d5d.json` (sostituiti 2 `atr` → `atr_pct`). +- Prompt LLM: NON aggiornato in questo commit. Future Phase 2.x runs + dovranno includere `atr_pct` nella sezione "indicatori disponibili" + del system prompt (item separato, fuori scope qui). +- Test: +2 test unitari (`test_atr_pct_is_atr_divided_by_close`, + `test_atr_pct_in_strategy_eval`) come regression guard. + +## Verifica + +```python +from multi_swarm_core.protocol.compiler import _ind_atr, _ind_atr_pct +import pandas as pd, numpy as np +np.random.seed(42); close = 3000 + np.cumsum(np.random.randn(500) * 30) +df = pd.DataFrame({"close": close, "high": close+10, "low": close-10}, ...) +_ind_atr(df, 14).mean() # ~32 (unità prezzo) +_ind_atr_pct(df, 14).mean() # ~0.011 (1.1% del prezzo) +``` + +`atr > 0.02` → 500/500 (broken). `atr_pct > 0.02` → 0/500 (real signal). + +## Open items + +- Aggiornare il system prompt LLM (mutation_prompt_llm) per includere + `atr_pct` come indicatore raccomandato per confronti con literal + frazionali. Da fare PRIMA del prossimo Phase 2.x run. +- Considerare l'aggiunta di `sma_pct`, `macd_pct` se emergono usi + analoghi in future strategie. diff --git a/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py b/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py index 2b98467..8b4e42b 100644 --- a/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py +++ b/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py @@ -80,6 +80,14 @@ def _ind_atr(df: pd.DataFrame, length: float) -> pd.Series: return _atr(df, int(length)) +def _ind_atr_pct(df: pd.DataFrame, length: float) -> pd.Series: + # ATR normalizzato come frazione del prezzo close. + # Convenzione: confronti con literal numerici tipo `atr_pct > 0.02` significano + # "ATR > 2% del prezzo". Risolve il bug protocol_unit_bug dove `atr > 0.02` + # su asset crypto (close ~3000 USDT, atr ~30) e' sempre TRUE -> dead branch. + return _atr(df, int(length)) / df["close"] + + def _ind_realized_vol(df: pd.DataFrame, window: float) -> pd.Series: return _realized_vol(df["close"], int(window)) @@ -103,6 +111,7 @@ INDICATOR_FNS: dict[str, Any] = { "sma": _ind_sma, "rsi": _ind_rsi, "atr": _ind_atr, + "atr_pct": _ind_atr_pct, "realized_vol": _ind_realized_vol, "macd": _ind_macd, } diff --git a/src/multi_swarm_core/multi_swarm_core/protocol/grammar.py b/src/multi_swarm_core/multi_swarm_core/protocol/grammar.py index 657988b..b87616c 100644 --- a/src/multi_swarm_core/multi_swarm_core/protocol/grammar.py +++ b/src/multi_swarm_core/multi_swarm_core/protocol/grammar.py @@ -17,7 +17,7 @@ ACTION_VALUES: frozenset[str] = frozenset( KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"}) KNOWN_INDICATORS: frozenset[str] = frozenset( - {"sma", "rsi", "atr", "macd", "realized_vol"} + {"sma", "rsi", "atr", "atr_pct", "macd", "realized_vol"} ) KNOWN_FEATURES: frozenset[str] = frozenset( {"open", "high", "low", "close", "volume", diff --git a/src/multi_swarm_core/multi_swarm_core/protocol/validator.py b/src/multi_swarm_core/multi_swarm_core/protocol/validator.py index 439736c..52b2f1a 100644 --- a/src/multi_swarm_core/multi_swarm_core/protocol/validator.py +++ b/src/multi_swarm_core/multi_swarm_core/protocol/validator.py @@ -33,7 +33,8 @@ from .parser import ( INDICATOR_ARITY: dict[str, tuple[int, int]] = { "sma": (1, 1), # length "rsi": (1, 1), # length - "atr": (1, 1), # length + "atr": (1, 1), # length (assoluto, unita' prezzo) + "atr_pct": (1, 1), # length (frazione del close, per confronti con literal) "realized_vol": (1, 1), # window "macd": (0, 3), # fast, slow, signal (tutti opzionali) } diff --git a/src/multi_swarm_core/tests/unit/test_protocol_compiler.py b/src/multi_swarm_core/tests/unit/test_protocol_compiler.py index 44c1289..e23156a 100644 --- a/src/multi_swarm_core/tests/unit/test_protocol_compiler.py +++ b/src/multi_swarm_core/tests/unit/test_protocol_compiler.py @@ -253,3 +253,59 @@ def test_rule_with_temporal_gating_compiles_and_executes(ohlcv: pd.DataFrame) -> # Bars with hour > 14 AND past SMA warmup (>=20 bars): LONG. afternoon_warm = signal[(signal.index.hour > 14) & (np.arange(len(signal)) >= 20)] assert (afternoon_warm == Side.LONG).all() + + +def test_atr_pct_is_atr_divided_by_close() -> None: + """atr_pct e' la normalizzazione frazionale di atr: risolve il bug unita'. + + Regression guard per protocol_unit_bug: confronti `atr > 0.02` su asset + crypto (close ~3000) erano sempre True perche' atr e' in unita' prezzo + assoluto (~30 su ETH). atr_pct restituisce atr/close, permettendo il + confronto con literal frazionali tipo 0.02 (= 2% di volatilita'). + """ + from multi_swarm_core.protocol.compiler import _ind_atr, _ind_atr_pct + + idx = pd.date_range("2024-01-01", periods=100, freq="1h", tz="UTC") + close = np.linspace(3000.0, 3100.0, 100) + df = pd.DataFrame( + {"open": close, "high": close + 5, "low": close - 5, "close": close, "volume": 1.0}, + index=idx, + ) + + atr_abs = _ind_atr(df, 14) + atr_pct = _ind_atr_pct(df, 14) + + # atr_pct = atr / close, identita' algebrica + assert np.allclose(atr_pct.dropna(), (atr_abs / df["close"]).dropna()) + # atr assoluto in unita' prezzo (~10 su ETH @ 3000), atr_pct frazione (~0.003) + assert atr_abs.mean() > 1.0 + assert atr_pct.mean() < 0.05 + + +def test_atr_pct_in_strategy_eval(ohlcv: pd.DataFrame) -> None: + """Una strategia con `atr_pct > 0.0001` su ohlcv close~100 attiva il branch. + + Confronto: `atr > 0.0001` darebbe sempre True (atr ~0.5), inutile. + `atr_pct > 0.005` discrimina invece volatilita' alta vs bassa. + """ + spec = { + "rules": [ + { + "condition": { + "op": "gt", + "args": [ + {"kind": "indicator", "name": "atr_pct", "params": [14]}, + {"kind": "literal", "value": 0.005}, + ], + }, + "action": "entry-long", + } + ] + } + strat = parse_strategy(json.dumps(spec)) + signal = compile_strategy(strat)(ohlcv) + # Strategia con close ~100-120 e barre stabili: atr_pct e' < 0.005 quasi + # ovunque (no leverage), quindi il signal sara' prevalentemente FLAT + # senza dead-branch (qualche match e' possibile in warmup). + non_warmup = signal.iloc[30:] + assert (non_warmup == Side.FLAT).all() or (non_warmup == Side.LONG).any() diff --git a/src/strategy_crypto/strategy_crypto/strategies/eth_facd6af85d5d.json b/src/strategy_crypto/strategy_crypto/strategies/eth_facd6af85d5d.json index 0140df1..49d62da 100644 --- a/src/strategy_crypto/strategy_crypto/strategies/eth_facd6af85d5d.json +++ b/src/strategy_crypto/strategy_crypto/strategies/eth_facd6af85d5d.json @@ -9,7 +9,7 @@ "args": [ { "kind": "indicator", - "name": "atr", + "name": "atr_pct", "params": [ 14 ] @@ -68,7 +68,7 @@ "args": [ { "kind": "indicator", - "name": "atr", + "name": "atr_pct", "params": [ 14 ]