feat(agents): estendi input LLM con 4 statistiche regime-aware + directive v2.1

Aggiunge al USER_TEMPLATE dell'HypothesisAgent 4 metriche calcolate su rolling
window 500 barre (no backward bias del full sample):
  - autocorr_lag1_recent vs autocorr_lag1_baseline (AR(1) delta vs storico)
  - hurst_recent (R/S analysis, persistenza di scala)
  - vol_percentile (0-100, posizione vol corrente nella distribuzione storica)
  - seasonality_hour, seasonality_dow (0-1, varianza spiegata da feature temporali)

Razionale: skew/kurt da soli sono ambigui — un AR(1) discrimina momentum vs
mean-reversion meglio di tutta la guidance sui momenti.

NEW funzioni in metrics/basic.py:
  - autocorr_lag1(returns)
  - hurst_exponent(returns) via R/S a scale multiple
  - vol_percentile_historical(returns, current_window=24, ref_window=2000)
  - seasonality_strength(returns, by={"hour"|"dow"})

MarketSummary esteso con 6 nuovi campi (con default); build_market_summary calcola
rolling-500 per "recent", full sample per "baseline".

prompts.json v2.1: tutte le 7 directive estese con frase di interpretazione
style-specific dei 4 nuovi input (no style collapse). Es:
  - physicist: "AR(1) sopra baseline = sistema con memoria fuori equilibrio"
  - engineer: "AR(1) > 0.05 con std contenuta = SNR favorevole"
  - psychologist: "AR(1) recente >> baseline = euforia coordinata"

Tests: +16 unit per le metriche, +1 smoke per MarketSummary populated.

Verifica: 207 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-15 20:43:58 +00:00
parent a43157cd44
commit 0fd31d52ec
6 changed files with 346 additions and 1 deletions
@@ -21,6 +21,12 @@ class MarketSummary:
skew: float
kurtosis: float
volatility_regime: str
autocorr_lag1_recent: float = 0.0 # AR(1) ultimi 500 bar
autocorr_lag1_baseline: float = 0.0 # AR(1) full sample (riferimento)
hurst_recent: float = 0.5 # Hurst ultimi 500 bar
vol_percentile: float = 50.0 # 0-100 percentile della vol corrente
seasonality_hour: float = 0.0 # 0-1 varianza spiegata da hour
seasonality_dow: float = 0.0 # 0-1 varianza spiegata da dow
@dataclass(frozen=True)
@@ -181,6 +187,12 @@ Statistiche return: mean={return_mean:.5f}, std={return_std:.5f}, \
skew={skew:.3f}, kurt={kurtosis:.3f}.
Regime volatilità: {volatility_regime}.
Regime recente (ultime 500 barre):
autocorr_lag1: {autocorr_lag1_recent:.3f} (baseline storica: {autocorr_lag1_baseline:.3f})
hurst: {hurst_recent:.3f} (0.5 = random walk, >0.55 trending, <0.45 mean rev)
vol_pct: {vol_percentile:.0f}° percentile storico
stagionalita: hour={seasonality_hour:.2f}, dow={seasonality_dow:.2f} (0-1, varianza spiegata)
Feature accessibili dal tuo genoma: {feature_access}.
Lookback massimo che puoi usare nel ragionamento: {lookback_window} barre.
@@ -294,6 +306,12 @@ class HypothesisAgent:
skew=market.skew,
kurtosis=market.kurtosis,
volatility_regime=market.volatility_regime,
autocorr_lag1_recent=market.autocorr_lag1_recent,
autocorr_lag1_baseline=market.autocorr_lag1_baseline,
hurst_recent=market.hurst_recent,
vol_percentile=market.vol_percentile,
seasonality_hour=market.seasonality_hour,
seasonality_dow=market.seasonality_dow,
feature_access=", ".join(genome.feature_access),
lookback_window=genome.lookback_window,
)
@@ -3,6 +3,12 @@ from __future__ import annotations
import pandas as pd # type: ignore[import-untyped]
from scipy import stats # type: ignore[import-untyped]
from ..metrics.basic import (
autocorr_lag1,
hurst_exponent,
seasonality_strength,
vol_percentile_historical,
)
from .hypothesis import MarketSummary
@@ -24,6 +30,16 @@ def build_market_summary(
else:
regime = "high"
recent_window = min(500, len(returns))
recent_returns = returns.iloc[-recent_window:]
autocorr_recent = autocorr_lag1(recent_returns)
autocorr_baseline = autocorr_lag1(returns)
hurst_r = hurst_exponent(recent_returns)
vol_pct = vol_percentile_historical(returns, current_window=24, ref_window=2000)
season_h = seasonality_strength(returns, by="hour")
season_d = seasonality_strength(returns, by="dow")
return MarketSummary(
symbol=symbol,
timeframe=timeframe,
@@ -33,4 +49,10 @@ def build_market_summary(
skew=skew,
kurtosis=kurt,
volatility_regime=regime,
autocorr_lag1_recent=autocorr_recent,
autocorr_lag1_baseline=autocorr_baseline,
hurst_recent=hurst_r,
vol_percentile=vol_pct,
seasonality_hour=season_h,
seasonality_dow=season_d,
)
@@ -4,6 +4,122 @@ import numpy as np
import pandas as pd # type: ignore[import-untyped]
def autocorr_lag1(returns: pd.Series) -> float:
"""Autocorrelazione dei ritorni con lag 1 (correlazione di Pearson)."""
if len(returns) < 2:
return 0.0
val = returns.autocorr(lag=1)
return float(val) if pd.notna(val) else 0.0
def hurst_exponent(returns: pd.Series) -> float:
"""Hurst exponent via R/S analysis (rescaled range).
Range 0-1: 0.5 random walk, >0.55 trending persistente, <0.45 mean reverting.
Implementazione classica con scale multiple (2^k bins).
"""
n = len(returns)
if n < 100:
return 0.5 # insufficiente dati, ritorna random-walk default
series = returns.dropna().values
n = len(series)
if n < 100:
return 0.5
# Scale: 2^k che dividono n in segmenti
scales = [2**k for k in range(4, int(np.log2(n // 2)) + 1)]
if not scales:
return 0.5
rs_values: list[float] = []
for scale in scales:
n_chunks = n // scale
if n_chunks < 1:
continue
rs_chunk: list[float] = []
for i in range(n_chunks):
chunk = series[i * scale : (i + 1) * scale]
mean = chunk.mean()
cumdev = (chunk - mean).cumsum()
r = cumdev.max() - cumdev.min()
s = chunk.std()
if s > 0:
rs_chunk.append(r / s)
if rs_chunk:
rs_values.append(float(np.mean(rs_chunk)))
if len(rs_values) < 2:
return 0.5
log_scales = np.log(scales[: len(rs_values)])
log_rs = np.log(rs_values)
# Hurst = slope della regressione log-log
h, _ = np.polyfit(log_scales, log_rs, 1)
return float(np.clip(h, 0.0, 1.0))
def vol_percentile_historical(
returns: pd.Series,
current_window: int = 24,
ref_window: int = 2000,
) -> float:
"""Percentile (0-100) della vol corrente nella distribuzione storica.
Vol = std rolling su current_window barre. Confronta l'ultimo valore contro
la distribuzione dei valori della stessa std rolling sugli ultimi ref_window.
Output: 0 (vol attuale tra le piu basse), 100 (tra le piu alte).
"""
if len(returns) < max(current_window, 100):
return 50.0
rolling_vol = returns.rolling(current_window, min_periods=current_window).std()
rolling_vol = rolling_vol.dropna()
if len(rolling_vol) < 10:
return 50.0
# Limita ref_window all'effettiva disponibilita
ref = rolling_vol.iloc[-ref_window:] if len(rolling_vol) > ref_window else rolling_vol
current = float(rolling_vol.iloc[-1])
pct = float((ref < current).sum()) / len(ref) * 100.0
return pct
def seasonality_strength(
returns: pd.Series,
by: str,
) -> float:
"""Frazione di varianza dei ritorni spiegata dalla feature temporale `by`.
`by` in {"hour", "dow"}. Output 0-1: 0 = no seasonality, 1 = tutta la varianza
e dovuta al ciclo. Calcolato come 1 - (var residua / var totale) usando i
gruppi indotti dalla feature.
"""
if not isinstance(returns.index, pd.DatetimeIndex):
return 0.0
if len(returns) < 50:
return 0.0
if by == "hour":
groups = returns.index.hour
elif by == "dow":
groups = returns.index.dayofweek
else:
raise ValueError(f"by deve essere 'hour' o 'dow', non {by!r}")
total_var = float(returns.var())
if total_var <= 0:
return 0.0
grouped = returns.groupby(groups)
group_means = grouped.transform("mean")
residuals = returns - group_means
residual_var = float(residuals.var())
explained = 1.0 - (residual_var / total_var)
return float(np.clip(explained, 0.0, 1.0))
def sharpe_ratio(returns: pd.Series, periods_per_year: int = 8760, rf: float = 0.0) -> float:
"""Sharpe annualizzato. periods_per_year=8760 per dati orari."""
excess = returns - rf / periods_per_year
@@ -31,3 +31,35 @@ def test_volatility_regime_high_for_volatile() -> None:
)
s = build_market_summary(df, symbol="BTC/USDT", timeframe="1h")
assert s.volatility_regime in {"medium", "high"}
def test_build_summary_new_fields_populated() -> None:
"""I 6 nuovi campi devono essere float nei range attesi."""
idx = pd.date_range("2024-01-01", periods=600, freq="1h", tz="UTC")
np.random.seed(42)
close = 100 + np.cumsum(np.random.normal(0, 1, 600))
df = pd.DataFrame(
{"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0},
index=idx,
)
s = build_market_summary(df, symbol="ETH/USDT", timeframe="1h")
# autocorr fields: float in [-1, 1]
assert isinstance(s.autocorr_lag1_recent, float)
assert isinstance(s.autocorr_lag1_baseline, float)
assert -1.0 <= s.autocorr_lag1_recent <= 1.0
assert -1.0 <= s.autocorr_lag1_baseline <= 1.0
# hurst: float in [0, 1]
assert isinstance(s.hurst_recent, float)
assert 0.0 <= s.hurst_recent <= 1.0
# vol_percentile: float in [0, 100]
assert isinstance(s.vol_percentile, float)
assert 0.0 <= s.vol_percentile <= 100.0
# seasonality fields: float in [0, 1]
assert isinstance(s.seasonality_hour, float)
assert isinstance(s.seasonality_dow, float)
assert 0.0 <= s.seasonality_hour <= 1.0
assert 0.0 <= s.seasonality_dow <= 1.0
@@ -2,7 +2,15 @@ import numpy as np
import pandas as pd
import pytest
from multi_swarm_core.metrics.basic import max_drawdown, sharpe_ratio, total_return
from multi_swarm_core.metrics.basic import (
autocorr_lag1,
hurst_exponent,
max_drawdown,
seasonality_strength,
sharpe_ratio,
total_return,
vol_percentile_historical,
)
def test_sharpe_zero_returns():
@@ -38,3 +46,123 @@ def test_max_drawdown_known_curve():
def test_total_return():
eq = pd.Series([100.0, 110.0, 105.0, 120.0])
assert total_return(eq) == pytest.approx(0.20)
# --- New metrics tests ---
def test_autocorr_lag1_short_series():
"""Serie corta ritorna 0.0 senza errori."""
r = pd.Series([0.01])
assert autocorr_lag1(r) == 0.0
def test_autocorr_lag1_all_nan():
"""Serie di NaN ritorna 0.0."""
r = pd.Series([float("nan")] * 10)
assert autocorr_lag1(r) == 0.0
def test_autocorr_lag1_range():
"""Autocorrelazione sempre in [-1, 1]."""
np.random.seed(7)
r = pd.Series(np.random.normal(0, 0.01, 500))
val = autocorr_lag1(r)
assert -1.0 <= val <= 1.0
def test_autocorr_lag1_trending_positive():
"""Serie con autocorrelazione positiva artificiale deve dare val > 0."""
# costruiamo una serie con forte AR(1): x_t = 0.8*x_{t-1} + noise
np.random.seed(1)
n = 300
x = np.zeros(n)
for i in range(1, n):
x[i] = 0.8 * x[i - 1] + np.random.normal(0, 0.1)
r = pd.Series(x)
assert autocorr_lag1(r) > 0.5
def test_hurst_short_series():
"""Serie < 100 elementi ritorna 0.5 (random-walk default)."""
r = pd.Series(np.random.normal(0, 0.01, 50))
assert hurst_exponent(r) == pytest.approx(0.5)
def test_hurst_range():
"""Hurst sempre in [0, 1]."""
np.random.seed(3)
r = pd.Series(np.random.normal(0, 0.01, 500))
h = hurst_exponent(r)
assert 0.0 <= h <= 1.0
def test_hurst_random_walk_approx_half():
"""Random walk iid deve avere Hurst vicino a 0.5."""
np.random.seed(42)
r = pd.Series(np.random.normal(0, 0.01, 2000))
h = hurst_exponent(r)
assert 0.3 <= h <= 0.7
def test_vol_percentile_short_series():
"""Serie troppo corta ritorna 50.0."""
r = pd.Series(np.random.normal(0, 0.01, 10))
assert vol_percentile_historical(r) == pytest.approx(50.0)
def test_vol_percentile_range():
"""Percentile sempre in [0, 100]."""
np.random.seed(5)
r = pd.Series(np.random.normal(0, 0.01, 500))
p = vol_percentile_historical(r, current_window=24, ref_window=200)
assert 0.0 <= p <= 100.0
def test_vol_percentile_high_vol_at_end():
"""Vol molto alta alla fine deve dare percentile elevato."""
np.random.seed(9)
low_vol = np.random.normal(0, 0.001, 400)
high_vol = np.random.normal(0, 0.05, 100)
r = pd.Series(np.concatenate([low_vol, high_vol]))
p = vol_percentile_historical(r, current_window=24, ref_window=400)
assert p > 70.0
def test_seasonality_strength_no_datetimeindex():
"""Senza DatetimeIndex ritorna 0.0."""
r = pd.Series(np.random.normal(0, 0.01, 200))
assert seasonality_strength(r, by="hour") == 0.0
def test_seasonality_strength_short_series():
"""Serie < 50 elementi ritorna 0.0."""
idx = pd.date_range("2024-01-01", periods=30, freq="1h", tz="UTC")
r = pd.Series(np.random.normal(0, 0.01, 30), index=idx)
assert seasonality_strength(r, by="hour") == 0.0
def test_seasonality_strength_range():
"""Risultato sempre in [0, 1]."""
np.random.seed(11)
idx = pd.date_range("2024-01-01", periods=500, freq="1h", tz="UTC")
r = pd.Series(np.random.normal(0, 0.01, 500), index=idx)
val = seasonality_strength(r, by="hour")
assert 0.0 <= val <= 1.0
def test_seasonality_strength_dow_range():
"""Stagionalita dow sempre in [0, 1]."""
np.random.seed(13)
idx = pd.date_range("2024-01-01", periods=500, freq="1h", tz="UTC")
r = pd.Series(np.random.normal(0, 0.01, 500), index=idx)
val = seasonality_strength(r, by="dow")
assert 0.0 <= val <= 1.0
def test_seasonality_strength_invalid_by():
"""by non valido solleva ValueError."""
idx = pd.date_range("2024-01-01", periods=100, freq="1h", tz="UTC")
r = pd.Series(np.random.normal(0, 0.01, 100), index=idx)
with pytest.raises(ValueError, match="'minute'"):
seasonality_strength(r, by="minute")
@@ -0,0 +1,29 @@
{
"_comment": "Stili cognitivi e direttive del system_prompt per il GA di strategy_crypto. Modifica liberamente: cambia 'directive' di uno stile esistente o aggiungi nuove voci a 'styles'. Il nome dello stile (key) viene usato come 'cognitive_style' del genoma; la 'directive' diventa il system_prompt iniziale.",
"_schema": "2.1",
"_changelog": "v2.1 - directive estese con interpretazione style-specific dei 4 nuovi input statistici (autocorr_lag1, hurst, vol_pct, seasonality). v2.0 - Riprogettato per blind-generator GA. Directive medio-compatte (~700 char) che orientano l'esplorazione cognitiva senza prescrivere indicatori specifici (lascia evolvere il GA). Mappate sulle 4 statistiche disponibili: mean, std, skew, kurtosis + volatility_regime. Rimosse ecologist (richiede multi-asset), game_theorist/epidemiologist (richiedono info esterne non visibili all'agente). Tenute 7 lenti che mappano bene sulle statistiche aggregate.",
"_design_notes": "Le directive sono BIAS DI ESPLORAZIONE, non template. Suggeriscono cosa cercare nei 4 momenti e quali archetipi di strategia preferire, lasciando al GA la scoperta della combinazione esatta di indicatori e soglie. Sono pensate per essere riscritte dall'operatore mutate_prompt_llm mantenendo coerenza con la lente.",
"styles": {
"physicist": {
"directive": "Pensa come un fisico: il mercato e un sistema con leggi di conservazione e regimi di scala. Leggi std come dispersione energetica e kurtosis come densita di eventi estremi (kurt alta = fat tails, sistema fuori equilibrio). Cerca simmetrie nei ritorni (skew ≈0 = sistema simmetrico) e rotture (skew marcato = forzante asimmetrica). AR(1) positivo significativamente sopra baseline = sistema con memoria fuori equilibrio, momentum legittimo; Hurst > 0.55 conferma persistenza di scala; vol percentile alto + kurt bassa = energia immagazzinata non ancora rilasciata. Preferisci ritorno all'equilibrio in regime simmetrico/basso vol, propagazione (momentum/breakout) in regime asimmetrico/alto vol. Pattern coerenti su piu lookback sono robusti, pattern singoli sono rumore."
},
"biologist": {
"directive": "Pensa come un biologo evoluzionista: il mercato e un ecosistema di strategie in competizione. Skew negativo = predazione asimmetrica (vol-selling crowded che subisce shock). Skew positivo = predatori che cacciano breakout. Kurt alta = eventi di estinzione/fioritura. AR(1) positivo persistente = una specie sta colonizzando la nicchia (overcrowding imminente, fade); Hurst > 0.55 con vol percentile basso = nicchia stabile (occupa); seasonality forte = ritmo biologico ricorrente, sfruttabile. Preferisci contrarian in regime di skew estremo (fade la specie dominante) e coordinamento in regime simmetrico. Pattern asimmetrici: cattura la coda opposta al consensus."
},
"historian": {
"directive": "Pensa come uno storico: i regimi si ripetono ma non identici. Usa mean come drift strutturale e std come ampiezza ciclo. Kurt alta + vol regime medium/high = fase tardiva (eventi estremi addensati, pre-transizione); kurt bassa + skew ≈0 = accumulazione/stabilita. AR(1) recente >> baseline storica = regime sta accelerando rispetto al normale; Hurst > 0.55 + vol percentile alto = fase markup matura, mean reversion attesa; seasonality forte = abitudini collettive consolidate, replicabili. Preferisci mean reversion strutturali: deviazioni significative tendono a ritornare su orizzonti multipli. Identifica analogie tra regime corrente e fasi tipiche (compressione vol, espansione, esaurimento trend)."
},
"meteorologist": {
"directive": "Pensa come un meteorologo: la volatilita ha regimi persistenti con transizioni brusche. Vol regime e input primario. Std + kurt = microclima: std bassa + kurt bassa = calma stabile (vendi vol), std alta + kurt alta = tempesta (compra convexity), std bassa + kurt alta = calma ingannevole (riduci esposizione). AR(1) recente > baseline = fronte persistente in arrivo; Hurst > 0.55 = sistema su scala lunga (ciclone), Hurst < 0.45 = turbolenza locale (no trend persistente); vol percentile estremo = posizione nel ciclo seasonal; seasonality alta = pattern cyclonico ricorrente. Strategie regime-aware: gate espliciti su vol che attivano logiche diverse. Pattern multi-regime sono robusti."
},
"engineer": {
"directive": "Pensa come un ingegnere di controllo: ogni segnale deve avere SNR favorevole e robustezza. Std e il rumore di fondo: il segnale deve essere significativamente piu grande della varianza intraday. Kurt alta riduce affidabilita dei segnali medi. AR(1) > 0.05 con std contenuta = SNR favorevole, segnale azionabile; AR(1) ≈0 = random walk, non costruirci sopra; Hurst < 0.45 = filtro mean-reversion causale efficace; vol percentile > 80 = saturazione (sensori instabili, riduci leverage); seasonality < 0.05 = feature temporali sono rumore, NON usarle. Pattern semplici e tarabili: poche condizioni in AND, soglie con margine, isteresi entry/exit. Robustezza > ottimalita su singolo regime."
},
"military_strategist": {
"directive": "Pensa come uno stratega: distingui regime offensivo da difensivo. Vol regime medium/low + skew positivo + kurt moderata = terreno favorevole all'attacco (entry direzionali su breakout/momentum). Vol regime high + kurt elevata = terreno ostile (difesa: posizioni limitate, exit rapide). AR(1) > 0 = vento alle spalle, carica con momentum; AR(1) < 0 = imboscata possibile, contrarian; Hurst > 0.55 = posizione difendibile (hold trade); vol percentile alta = artiglieria nemica attiva (ritirata); seasonality forte = via predicibile da percorrere, sfruttala. Concentrazione: poche condizioni forti. Economia: flat quando il segnale non e dominante. Sorpresa: contrarian su skew estremo (consensus). Sicurezza: ogni entry con exit chiara."
},
"psychologist": {
"directive": "Pensa come uno psicologo del comportamento collettivo: skew e kurt catturano emozioni. Skew neg + kurt alta = paura ricorrente (capitulation spikes, fade gli estremi al ribasso). Skew pos + kurt alta = euforia (FOMO spikes, fade gli estremi al rialzo). Skew ≈0 + kurt bassa = apatia/range (gioca i bordi). AR(1) recente >> baseline = euforia coordinata in corso, posizionati contro l'ultimo arrivato; Hurst > 0.55 = trance collettiva (trend trance, dura piu del razionale); vol percentile estremo + kurt alta = momentum emozionale puro; seasonality intra-day forte = bias circadiani sfruttabili (FOMO apertura US, panico chiusura asiatica). Estremi di oscillatori in regimi emotivi (kurt alta), crossover in regimi razionali (kurt ≈3). Contrarian sugli estremi, continuazione sulle medie."
}
}
}