feat(protocol): aggiungi sma_pct + macd_pct per completare la famiglia *_pct

Estende il fix atr_pct (commit f875df3 / 9c3b5ad) coprendo anche SMA e MACD:

protocol/compiler.py:
- _ind_sma_pct(length) = (close - sma) / sma
  Deviazione frazionale del close dalla SMA. Range tipico ±0.1.
  NB: NON e' sma/close (sempre ~1.0, inutile per literal). Uso ideale:
    sma_pct(50) > 0.05 -> "close 5% sopra media a 50 barre" (mean reversion)
- _ind_macd_pct(fast, slow, signal) = macd / close
  MACD come frazione del prezzo. Range tipico ±0.02. Uso ideale:
    macd_pct(12,26,9) > 0.005 -> "momentum > 0.5% del prezzo"

protocol/grammar.py: KNOWN_INDICATORS estesa con sma_pct + macd_pct
protocol/validator.py: arity (1,1) per sma_pct, (0,3) per macd_pct (come macd)

agents/hypothesis.py (system prompt LLM):
- Lista indicatori include sma_pct e macd_pct con annotazioni unita'
- Esempi corretti/errati estesi: sma_pct > 0.05, macd_pct > 0.005
- Pattern guidance: "Mean reversion: sma_pct(long) > 0.05" e
  "Momentum positivo conferma: macd_pct(12,26,9) > 0.005"

genome/mutation_prompt_llm.py: keyword whitelist estesa con sma_pct + macd_pct

Tests (+3):
- test_sma_pct_is_close_deviation_from_sma: identita' algebrica + sign
- test_macd_pct_is_macd_divided_by_close: identita' + scala (rapporto ~close)
- test_sma_pct_and_macd_pct_in_validator: regression validator

Verifica: 191 pass (era 188).

Closes [[protocol_unit_bug]] in full. Family *_pct ora completa per atr/sma/macd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-15 18:28:21 +00:00
parent 9c3b5ad586
commit 96e08ff78f
7 changed files with 154 additions and 23 deletions
@@ -1,8 +1,8 @@
# Decisione: indicatore `atr_pct` per fix bug protocollo unità # Decisione: indicatori `*_pct` per fix bug protocollo unità
**Data:** 2026-05-15 **Data:** 2026-05-15
**Status:** Implementato **Status:** Implementato (atr_pct + sma_pct + macd_pct + prompt LLM aggiornato)
**Scope:** `multi_swarm_core.protocol.{compiler,grammar,validator}` + `strategy_crypto/strategies/eth_*.json` **Scope:** `multi_swarm_core.protocol.{compiler,grammar,validator}` + `agents/hypothesis.py` + `strategy_crypto/strategies/eth_*.json`
## Contesto ## Contesto
@@ -73,12 +73,25 @@ _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). `atr > 0.02` → 500/500 (broken). `atr_pct > 0.02` → 0/500 (real signal).
## Estensione: sma_pct + macd_pct (2026-05-15)
Aggiunti anche `sma_pct` e `macd_pct` per coerenza famiglia `*_pct`:
- **`sma_pct(length) = (close - sma) / sma`** — deviazione frazionale del
close dalla SMA. Range tipico ±0.1. NB: NON è `sma/close` (che sarebbe
sempre ~1.0, inutile per literal). Uso: `sma_pct(50) > 0.05` significa
"close 5% sopra la media a 50 barre" (mean reversion).
- **`macd_pct(fast, slow, signal) = macd / close`** — MACD come frazione
del prezzo. Range tipico ±0.02. Uso: `macd_pct > 0.005` significa
"momentum > 0.5% del prezzo".
Prompt LLM aggiornato di conseguenza (`agents/hypothesis.py`):
- Lista indicatori include `sma_pct` e `macd_pct` con annotazioni unità
- Pattern guidance: "Mean reversion strutturale: sma_pct(long) > 0.05",
"Momentum positivo conferma: macd_pct(12,26,9) > 0.005"
- 3 nuovi test (sma_pct identity, macd_pct identity, validator integration)
## Open items ## Open items
- ~~Aggiornare il system prompt LLM~~ ✅ **chiuso 2026-05-15**: - ~~Aggiornare il system prompt LLM~~ ✅ chiuso 2026-05-15
`agents/hypothesis.py` ora elenca `atr_pct` con annotazione unità e - ~~`sma_pct`, `macd_pct` se emergono usi analoghi~~ ✅ chiuso 2026-05-15
include una sezione "UNITÀ — REGOLA CRITICA" che spiega esplicitamente
al modello quando usare `atr_pct` (literal frazionali) vs `atr`
(confronti relativi). `mutation_prompt_llm._VALID_KEYWORDS` esteso.
- Considerare l'aggiunta di `sma_pct`, `macd_pct` se emergono usi
analoghi in future strategie (still open).
@@ -77,20 +77,29 @@ Crossover (eventi su 2 serie):
Leaf - indicatori (calcolati su close): Leaf - indicatori (calcolati su close):
{{"kind": "indicator", "name": "sma", "params": [<length>]}} // media mobile, UNITÀ PREZZO {{"kind": "indicator", "name": "sma", "params": [<length>]}} // media mobile, UNITÀ PREZZO
{{"kind": "indicator", "name": "sma_pct", "params": [<length>]}} // (close-sma)/sma, FRAZIONE ±0.1
{{"kind": "indicator", "name": "rsi", "params": [<length>]}} // 0-100, adimensionale {{"kind": "indicator", "name": "rsi", "params": [<length>]}} // 0-100, adimensionale
{{"kind": "indicator", "name": "atr", "params": [<length>]}} // true range, UNITÀ PREZZO {{"kind": "indicator", "name": "atr", "params": [<length>]}} // true range, UNITÀ PREZZO
{{"kind": "indicator", "name": "atr_pct", "params": [<length>]}} // atr/close, FRAZIONE 0.0-0.1 {{"kind": "indicator", "name": "atr_pct", "params": [<length>]}} // atr/close, FRAZIONE 0.0-0.1
{{"kind": "indicator", "name": "realized_vol", "params": [<window>]}} // std dei returns, FRAZIONE {{"kind": "indicator", "name": "realized_vol", "params": [<window>]}} // std dei returns, FRAZIONE
{{"kind": "indicator", "name": "macd", "params": [<fast>, <slow>, <signal>]}} {{"kind": "indicator", "name": "macd", "params": [<fast>, <slow>, <signal>]}} // UNITÀ PREZZO
// 0-3 numeri (tutti opzionali con default 12, 26, 9) {{"kind": "indicator", "name": "macd_pct", "params": [<fast>, <slow>, <signal>]}} // macd/close, FRAZIONE ±0.02
// params: 0-3 numeri (tutti opzionali, default 12, 26, 9)
UNITÀ — REGOLA CRITICA per i confronti con literal numerici: UNITÀ — REGOLA CRITICA per i confronti con literal numerici:
* Confronti con literal FRAZIONALI (0.01, 0.02, 0.05): usa `atr_pct`, `realized_vol` * Confronti con literal FRAZIONALI (0.01, 0.02, 0.05): usa le varianti _pct
Esempio CORRETTO: `atr_pct(14) > 0.02` significa "ATR > 2% del prezzo" Esempi CORRETTI:
Esempio ERRATO: `atr(14) > 0.02` è sempre TRUE su asset $>1 (atr in dollari) `atr_pct(14) > 0.02` "ATR > 2% del prezzo" (volatilità alta)
* Confronti RELATIVI fra indicatori in stessa unità: usa `atr`, `sma`, `macd` `sma_pct(50) > 0.05` "close 5% sopra SMA(50)" (deviazione media)
Esempio: `atr(14) > sma(14)` (entrambi in dollari, confronto valido) `macd_pct(12,26,9) > 0.005` "momentum > 0.5% del prezzo"
* RSI usa literal 0-100 (mai frazione): `rsi(14) > 70` Esempi ERRATI (sempre TRUE/FALSE su crypto, dead branch):
`atr(14) > 0.02` atr in dollari (~30 su ETH) >> 0.02
`sma(50) > 0.02` sma in dollari (~3000) >> 0.02
`macd > 0.02` macd in dollari, ordine ±10
* Confronti RELATIVI fra indicatori in stessa unità: usa nomi senza _pct
Esempi: `atr(14) > sma(14)` (entrambi $), `sma(50) > sma(200)` (golden cross)
`close > sma(50)` (entrambi $) — preferito su `sma_pct(50) > 0` (equivalente)
* RSI usa literal 0-100 (mai frazione): `rsi(14) > 70`, `rsi(14) < 30`
Leaf - feature OHLCV: Leaf - feature OHLCV:
{{"kind": "feature", "name": "open|high|low|close|volume"}} {{"kind": "feature", "name": "open|high|low|close|volume"}}
@@ -121,7 +130,8 @@ PATTERN GUIDANCE (oltre agli indicatori, considera forma delle curve e ripetibil
- Trend discendente: SMA(short) < SMA(long) E close < SMA(short) - Trend discendente: SMA(short) < SMA(long) E close < SMA(short)
- Compressione di volatilità (pre-breakout): atr_pct(N) < 0.01 (sotto 1% del prezzo) - Compressione di volatilità (pre-breakout): atr_pct(N) < 0.01 (sotto 1% del prezzo)
- Espansione di volatilità: atr_pct(N) > 0.03 (sopra 3%) OPPURE ATR(N) > ATR(N*2) confronto relativo - Espansione di volatilità: atr_pct(N) > 0.03 (sopra 3%) OPPURE ATR(N) > ATR(N*2) confronto relativo
- Mean reversion strutturale: |close - SMA(long)| eccessivo → reversal atteso - Mean reversion strutturale: sma_pct(long) > 0.05 (close 5% sopra media) OR sma_pct(long) < -0.05
- Momentum positivo conferma: macd_pct(12,26,9) > 0.005 (> 0.5% del prezzo)
Ripetibilità dell'andamento: Ripetibilità dell'andamento:
- Eventi crossover/crossunder ricorrenti (golden/death cross, RSI cross zone) - Eventi crossover/crossunder ricorrenti (golden/death cross, RSI cross zone)
@@ -51,9 +51,9 @@ MUTATION_INSTRUCTIONS: dict[str, str] = {
# Keyword tecniche minime per validare che il prompt sia ancora "una strategia". # Keyword tecniche minime per validare che il prompt sia ancora "una strategia".
_VALID_KEYWORDS = ( _VALID_KEYWORDS = (
"rsi", "sma", "ema", "atr", "atr_pct", "realized_vol", "rsi", "sma", "sma_pct", "ema", "atr", "atr_pct", "realized_vol",
"momentum", "breakout", "mean", "reversion", "momentum", "breakout", "mean", "reversion",
"macd", "vwap", "bb", "bollinger", "stoch", "trend", "signal", "buy", "macd", "macd_pct", "vwap", "bb", "bollinger", "stoch", "trend", "signal", "buy",
"sell", "long", "short", "entry", "exit", "stop", "rule", "condition", "sell", "long", "short", "entry", "exit", "stop", "rule", "condition",
"if", "when", "and", "or", "gt", "lt", ">", "<", "ge", "le", "if", "when", "and", "or", "gt", "lt", ">", "<", "ge", "le",
"hour", "dow", "weekend", "indicator", "feature", "hour", "dow", "weekend", "indicator", "feature",
@@ -88,6 +88,29 @@ def _ind_atr_pct(df: pd.DataFrame, length: float) -> pd.Series:
return _atr(df, int(length)) / df["close"] return _atr(df, int(length)) / df["close"]
def _ind_sma_pct(df: pd.DataFrame, length: float) -> pd.Series:
# Deviazione frazionale del close dalla SMA: (close - sma) / sma.
# Range tipico +/- 0.1 (close +/- 10% dalla media). Uso ideale:
# sma_pct(50) > 0.05 -> "close 5% sopra la media a 50 barre"
# NB: non e' "sma/close" perche' quel valore (sempre ~1.0) e' inutile
# per confronti con literal frazionali.
sma = _sma(df["close"], int(length))
return (df["close"] - sma) / sma
def _ind_macd_pct(
df: pd.DataFrame,
fast: float = 12,
slow: float = 26,
signal: float = 9,
) -> pd.Series:
# MACD normalizzato come frazione del prezzo close: macd_value / close.
# Range tipico +/- 0.02. Uso: `macd_pct > 0` (momentum positivo) o
# `macd_pct > 0.005` (momentum positivo >= 0.5% del prezzo).
macd = _ind_macd(df, fast, slow, signal)
return macd / df["close"]
def _ind_realized_vol(df: pd.DataFrame, window: float) -> pd.Series: def _ind_realized_vol(df: pd.DataFrame, window: float) -> pd.Series:
return _realized_vol(df["close"], int(window)) return _realized_vol(df["close"], int(window))
@@ -109,11 +132,13 @@ def _ind_macd(
# against this map. # against this map.
INDICATOR_FNS: dict[str, Any] = { INDICATOR_FNS: dict[str, Any] = {
"sma": _ind_sma, "sma": _ind_sma,
"sma_pct": _ind_sma_pct,
"rsi": _ind_rsi, "rsi": _ind_rsi,
"atr": _ind_atr, "atr": _ind_atr,
"atr_pct": _ind_atr_pct, "atr_pct": _ind_atr_pct,
"realized_vol": _ind_realized_vol, "realized_vol": _ind_realized_vol,
"macd": _ind_macd, "macd": _ind_macd,
"macd_pct": _ind_macd_pct,
} }
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = { _TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
@@ -17,7 +17,7 @@ ACTION_VALUES: frozenset[str] = frozenset(
KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"}) KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"})
KNOWN_INDICATORS: frozenset[str] = frozenset( KNOWN_INDICATORS: frozenset[str] = frozenset(
{"sma", "rsi", "atr", "atr_pct", "macd", "realized_vol"} {"sma", "sma_pct", "rsi", "atr", "atr_pct", "macd", "macd_pct", "realized_vol"}
) )
KNOWN_FEATURES: frozenset[str] = frozenset( KNOWN_FEATURES: frozenset[str] = frozenset(
{"open", "high", "low", "close", "volume", {"open", "high", "low", "close", "volume",
@@ -31,12 +31,14 @@ from .parser import (
# Numero di parametri numerici accettati dopo il nome dell'indicatore. # Numero di parametri numerici accettati dopo il nome dell'indicatore.
# (min, max) sui soli numeri. Indicatori non sono annidabili in Phase 1. # (min, max) sui soli numeri. Indicatori non sono annidabili in Phase 1.
INDICATOR_ARITY: dict[str, tuple[int, int]] = { INDICATOR_ARITY: dict[str, tuple[int, int]] = {
"sma": (1, 1), # length "sma": (1, 1), # length (assoluto, unita' prezzo)
"sma_pct": (1, 1), # length: (close - sma)/sma, deviazione frazionale
"rsi": (1, 1), # length "rsi": (1, 1), # length
"atr": (1, 1), # length (assoluto, unita' prezzo) "atr": (1, 1), # length (assoluto, unita' prezzo)
"atr_pct": (1, 1), # length (frazione del close, per confronti con literal) "atr_pct": (1, 1), # length (frazione del close, per confronti con literal)
"realized_vol": (1, 1), # window "realized_vol": (1, 1), # window
"macd": (0, 3), # fast, slow, signal (tutti opzionali) "macd": (0, 3), # fast, slow, signal (tutti opzionali)
"macd_pct": (0, 3), # macd/close, frazionale (per confronti con literal)
} }
@@ -309,3 +309,84 @@ def test_atr_pct_in_strategy_eval(ohlcv: pd.DataFrame) -> None:
# senza dead-branch (qualche match e' possibile in warmup). # senza dead-branch (qualche match e' possibile in warmup).
non_warmup = signal.iloc[30:] non_warmup = signal.iloc[30:]
assert (non_warmup == Side.FLAT).all() or (non_warmup == Side.LONG).any() assert (non_warmup == Side.FLAT).all() or (non_warmup == Side.LONG).any()
def test_sma_pct_is_close_deviation_from_sma() -> None:
"""sma_pct = (close - sma) / sma: deviazione frazionale del close dalla SMA."""
from multi_swarm_core.protocol.compiler import _ind_sma, _ind_sma_pct
idx = pd.date_range("2024-01-01", periods=100, freq="1h", tz="UTC")
# Prezzo che cresce poi torna: sma_pct passa da +qualcosa a -qualcosa
close = np.concatenate([np.linspace(100, 120, 50), np.linspace(120, 95, 50)])
df = pd.DataFrame(
{"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0},
index=idx,
)
sma = _ind_sma(df, 20)
sma_pct = _ind_sma_pct(df, 20)
expected = (df["close"] - sma) / sma
assert np.allclose(sma_pct.dropna(), expected.dropna())
# In salita: close > sma -> sma_pct positivo
assert (sma_pct.iloc[30:50] > 0).any()
# In discesa estesa: close < sma -> sma_pct negativo
assert (sma_pct.iloc[80:] < 0).any()
def test_macd_pct_is_macd_divided_by_close() -> None:
"""macd_pct = macd / close: momentum normalizzato al prezzo."""
from multi_swarm_core.protocol.compiler import _ind_macd, _ind_macd_pct
idx = pd.date_range("2024-01-01", periods=300, freq="1h", tz="UTC")
# Random walk realistico su crypto (~3000 USDT, vol ~30/bar): MACD ha
# ampiezza non trascurabile vs trend lineare puro.
rng = np.random.default_rng(42)
close = 3000.0 + np.cumsum(rng.standard_normal(300) * 30)
df = pd.DataFrame(
{"open": close, "high": close + 5, "low": close - 5, "close": close, "volume": 1.0},
index=idx,
)
macd = _ind_macd(df, 12, 26, 9)
macd_pct = _ind_macd_pct(df, 12, 26, 9)
# Identita' algebrica: macd_pct == macd / close
assert np.allclose(macd_pct.dropna(), (macd / df["close"]).dropna())
# macd_pct ha scala << 1 (frazione del prezzo, ordine 1e-3)
assert macd_pct.abs().mean() < 0.05
# macd assoluto e' >> macd_pct (rapporto = close ~3000)
ratio = macd.abs().mean() / max(macd_pct.abs().mean(), 1e-12)
assert 1000 < ratio < 5000
def test_sma_pct_and_macd_pct_in_validator() -> None:
"""Regression: i nuovi indicatori sono accettati dal validator."""
from multi_swarm_core.protocol.validator import validate_strategy
spec = {
"rules": [
{
"condition": {
"op": "and",
"args": [
{
"op": "gt",
"args": [
{"kind": "indicator", "name": "sma_pct", "params": [50]},
{"kind": "literal", "value": 0.05},
],
},
{
"op": "gt",
"args": [
{"kind": "indicator", "name": "macd_pct", "params": [12, 26, 9]},
{"kind": "literal", "value": 0.005},
],
},
],
},
"action": "entry-long",
}
]
}
strat = parse_strategy(json.dumps(spec))
validate_strategy(strat) # no exception