fix(protocol): aggiungi atr_pct per risolvere bug unità ATR vs literal frazionali

Bug: ATR ritorna unità prezzo assoluto (~30 su ETH @ 3000 USDT), confronti
con literal 0.02/0.01 generavano dead branches (sempre TRUE / sempre FALSE).
Phase 3 baseline-001 abortita per questo motivo.

Fix chirurgico (no breaking change):
- compiler.py: nuovo _ind_atr_pct(df, length) = atr/close (frazione del prezzo)
- grammar.py: atr_pct aggiunto a KNOWN_INDICATORS
- validator.py: arity (1,1) per atr_pct
- atr classico preservato (usato in confronti relativi tipo `atr > sma`)

Strategy patch:
- eth_facd6af85d5d.json: 2 `atr` → `atr_pct` (le 2 condizioni con literal
  0.02 e 0.01 ora producono branch vivi)
- btc_fb63e851.json invariato (usa `atr > sma`, confronto relativo OK)

Tests:
- test_atr_pct_is_atr_divided_by_close: identita' algebrica + ranges expected
- test_atr_pct_in_strategy_eval: branch attivabile con literal frazionali
- 188 pass totale (186 + 2 nuovi)

Decision log: src/multi_swarm_core/docs/decisions/2026-05-15-atr-pct-fix.md

Open: aggiornare il system prompt LLM (mutation_prompt_llm) per includere
atr_pct PRIMA del prossimo Phase 2.x run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-15 18:15:01 +00:00
parent 720b2d58d7
commit f875df31b4
6 changed files with 152 additions and 4 deletions
@@ -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 `<indicator>_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.
@@ -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,
}
@@ -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",
@@ -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)
}
@@ -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()
@@ -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
]