From 96e08ff78f6b8c7edd9eb9b3ec9a14927ff92597 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 15 May 2026 18:28:21 +0000 Subject: [PATCH] feat(protocol): aggiungi sma_pct + macd_pct per completare la famiglia *_pct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../docs/decisions/2026-05-15-atr-pct-fix.md | 33 +++++--- .../multi_swarm_core/agents/hypothesis.py | 28 ++++--- .../genome/mutation_prompt_llm.py | 4 +- .../multi_swarm_core/protocol/compiler.py | 25 ++++++ .../multi_swarm_core/protocol/grammar.py | 2 +- .../multi_swarm_core/protocol/validator.py | 4 +- .../tests/unit/test_protocol_compiler.py | 81 +++++++++++++++++++ 7 files changed, 154 insertions(+), 23 deletions(-) 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 index 5727e8a..d4858ea 100644 --- 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 @@ -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 -**Status:** Implementato -**Scope:** `multi_swarm_core.protocol.{compiler,grammar,validator}` + `strategy_crypto/strategies/eth_*.json` +**Status:** Implementato (atr_pct + sma_pct + macd_pct + prompt LLM aggiornato) +**Scope:** `multi_swarm_core.protocol.{compiler,grammar,validator}` + `agents/hypothesis.py` + `strategy_crypto/strategies/eth_*.json` ## 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). +## 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 -- ~~Aggiornare il system prompt LLM~~ ✅ **chiuso 2026-05-15**: - `agents/hypothesis.py` ora elenca `atr_pct` con annotazione unità e - 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). +- ~~Aggiornare il system prompt LLM~~ ✅ chiuso 2026-05-15 +- ~~`sma_pct`, `macd_pct` se emergono usi analoghi~~ ✅ chiuso 2026-05-15 diff --git a/src/multi_swarm_core/multi_swarm_core/agents/hypothesis.py b/src/multi_swarm_core/multi_swarm_core/agents/hypothesis.py index 5a4a7d1..ba9f809 100644 --- a/src/multi_swarm_core/multi_swarm_core/agents/hypothesis.py +++ b/src/multi_swarm_core/multi_swarm_core/agents/hypothesis.py @@ -77,20 +77,29 @@ Crossover (eventi su 2 serie): Leaf - indicatori (calcolati su close): {{"kind": "indicator", "name": "sma", "params": []}} // media mobile, UNITÀ PREZZO + {{"kind": "indicator", "name": "sma_pct", "params": []}} // (close-sma)/sma, FRAZIONE ±0.1 {{"kind": "indicator", "name": "rsi", "params": []}} // 0-100, adimensionale {{"kind": "indicator", "name": "atr", "params": []}} // true range, UNITÀ PREZZO {{"kind": "indicator", "name": "atr_pct", "params": []}} // atr/close, FRAZIONE 0.0-0.1 {{"kind": "indicator", "name": "realized_vol", "params": []}} // std dei returns, FRAZIONE - {{"kind": "indicator", "name": "macd", "params": [, , ]}} - // 0-3 numeri (tutti opzionali con default 12, 26, 9) + {{"kind": "indicator", "name": "macd", "params": [, , ]}} // UNITÀ PREZZO + {{"kind": "indicator", "name": "macd_pct", "params": [, , ]}} // macd/close, FRAZIONE ±0.02 + // params: 0-3 numeri (tutti opzionali, default 12, 26, 9) UNITÀ — REGOLA CRITICA per i confronti con literal numerici: - * Confronti con literal FRAZIONALI (0.01, 0.02, 0.05): usa `atr_pct`, `realized_vol` - Esempio CORRETTO: `atr_pct(14) > 0.02` significa "ATR > 2% del prezzo" - Esempio ERRATO: `atr(14) > 0.02` è sempre TRUE su asset $>1 (atr in dollari) - * Confronti RELATIVI fra indicatori in stessa unità: usa `atr`, `sma`, `macd` - Esempio: `atr(14) > sma(14)` (entrambi in dollari, confronto valido) - * RSI usa literal 0-100 (mai frazione): `rsi(14) > 70` + * Confronti con literal FRAZIONALI (0.01, 0.02, 0.05): usa le varianti _pct + Esempi CORRETTI: + `atr_pct(14) > 0.02` "ATR > 2% del prezzo" (volatilità alta) + `sma_pct(50) > 0.05` "close 5% sopra SMA(50)" (deviazione media) + `macd_pct(12,26,9) > 0.005` "momentum > 0.5% del prezzo" + 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: {{"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) - 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 - - 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: - Eventi crossover/crossunder ricorrenti (golden/death cross, RSI cross zone) diff --git a/src/multi_swarm_core/multi_swarm_core/genome/mutation_prompt_llm.py b/src/multi_swarm_core/multi_swarm_core/genome/mutation_prompt_llm.py index 405beff..c2eb229 100644 --- a/src/multi_swarm_core/multi_swarm_core/genome/mutation_prompt_llm.py +++ b/src/multi_swarm_core/multi_swarm_core/genome/mutation_prompt_llm.py @@ -51,9 +51,9 @@ MUTATION_INSTRUCTIONS: dict[str, str] = { # Keyword tecniche minime per validare che il prompt sia ancora "una strategia". _VALID_KEYWORDS = ( - "rsi", "sma", "ema", "atr", "atr_pct", "realized_vol", + "rsi", "sma", "sma_pct", "ema", "atr", "atr_pct", "realized_vol", "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", "if", "when", "and", "or", "gt", "lt", ">", "<", "ge", "le", "hour", "dow", "weekend", "indicator", "feature", 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 8b4e42b..19e09bc 100644 --- a/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py +++ b/src/multi_swarm_core/multi_swarm_core/protocol/compiler.py @@ -88,6 +88,29 @@ def _ind_atr_pct(df: pd.DataFrame, length: float) -> pd.Series: 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: return _realized_vol(df["close"], int(window)) @@ -109,11 +132,13 @@ def _ind_macd( # against this map. INDICATOR_FNS: dict[str, Any] = { "sma": _ind_sma, + "sma_pct": _ind_sma_pct, "rsi": _ind_rsi, "atr": _ind_atr, "atr_pct": _ind_atr_pct, "realized_vol": _ind_realized_vol, "macd": _ind_macd, + "macd_pct": _ind_macd_pct, } _TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = { 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 b87616c..efc72e5 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", "atr_pct", "macd", "realized_vol"} + {"sma", "sma_pct", "rsi", "atr", "atr_pct", "macd", "macd_pct", "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 52b2f1a..6c979f8 100644 --- a/src/multi_swarm_core/multi_swarm_core/protocol/validator.py +++ b/src/multi_swarm_core/multi_swarm_core/protocol/validator.py @@ -31,12 +31,14 @@ from .parser import ( # Numero di parametri numerici accettati dopo il nome dell'indicatore. # (min, max) sui soli numeri. Indicatori non sono annidabili in Phase 1. 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 "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) + "macd_pct": (0, 3), # macd/close, frazionale (per confronti con literal) } 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 e23156a..878e19d 100644 --- a/src/multi_swarm_core/tests/unit/test_protocol_compiler.py +++ b/src/multi_swarm_core/tests/unit/test_protocol_compiler.py @@ -309,3 +309,84 @@ def test_atr_pct_in_strategy_eval(ohlcv: pd.DataFrame) -> None: # 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() + + +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