feat(protocol): aggiungi sma_pct + macd_pct per completare la famiglia *_pct
Estende il fix atr_pct (commitf875df3/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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user