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:
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user