refactor(protocol): swap S-expression grammar for strict JSON Schema

Sostituisce la grammatica S-expression con uno schema JSON stretto. La
grammatica S-expression falliva il parsing nel 64% delle generazioni del
modello Qwen3-235B sul run reale; JSON e' nativo per gli LLM moderni e
si parsa con json.loads.

Cambiamenti principali:
- grammar.py: costanti rinominate LOGICAL_OPS / COMPARATOR_OPS /
  CROSSOVER_OPS / ACTION_VALUES / KIND_VALUES.
- parser.py: nuovo AST a dataclass tipizzato (OpNode, IndicatorNode,
  FeatureNode, LiteralNode, Rule, Strategy); parse_strategy ora consuma
  JSON tramite json.loads.
- validator.py: walk dispatchato per tipo (isinstance) invece di
  pattern-matching su 'kind'; arity check su operatori e indicator.
- compiler.py: traversal del nuovo AST tipizzato, dispatch per
  isinstance; logica indicator/feature/literal invariata.
- hypothesis.py: prompt SYSTEM riscritto con esempi JSON e vincoli
  espliciti su no-nesting; estrazione via fence ```json``` + fallback
  brace-balanced.
- __init__.py: re-export pubblico delle entita' del protocollo.
- Tutti i test (parser, validator, compiler, hypothesis_agent,
  falsification, adversarial, e2e, smoke_run) migrati a JSON.
- Rimossa dipendenza sexpdata da pyproject.toml + uv.lock.

Test: 135 passed (era 122; aggiunti casi parser/validator).
ruff + mypy strict clean. Smoke run end-to-end OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 21:17:26 +02:00
parent df76906505
commit 44eb6436c1
16 changed files with 1082 additions and 392 deletions
+114 -42
View File
@@ -35,42 +35,76 @@ Sei un agente generatore di ipotesi di trading quantitativo per un sistema swarm
Il tuo stile cognitivo: {cognitive_style}
Direttiva personale: {system_prompt}
Devi proporre una strategia di trading espressa nel linguaggio S-expression
con i seguenti verbi disponibili:
Devi proporre una strategia di trading espressa in JSON STRETTO.
La risposta deve essere un singolo oggetto JSON dentro fence ```json...```
con questa shape:
Azioni: entry-long, entry-short, exit, flat
Logici: and, or, not
Comparatori: gt, lt, eq
Dati: feature, indicator, crossover, crossunder
```json
{{
"rules": [
{{"condition": <nodo>, "action": "entry-long|entry-short|exit|flat"}}
]
}}
```
Indicatori disponibili (calcolati implicitamente sul prezzo close):
sma <length>, rsi <length>, atr <length>, macd, realized_vol <window>.
Feature disponibili: open, high, low, close, volume.
NODI DISPONIBILI
REGOLE STRETTE DI SINTASSI:
- (indicator <name> <args...>) restituisce una serie numerica. Es.
(indicator rsi 14), (indicator sma 50), (indicator macd 12 26 9).
- (feature <name>) restituisce la colonna OHLCV. Es. (feature close).
- Gli indicatori NON sono annidabili: NON puoi scrivere
(sma (indicator realized_vol 30) 150) o (indicator rsi (feature high) 14).
Le funzioni sma/rsi/etc. ESISTONO SOLO come argomenti di indicator,
non sono verbi indipendenti.
- Costanti numeriche (es. 70.0, 30, 0.02) sono valide come 2° operando di gt/lt/eq.
- crossover/crossunder accettano due espressioni-serie:
(crossover (feature close) (indicator sma 20)) — corretto.
(crossover (sma close 20) (sma close 50)) — ERRATO (sma non è verbo).
Operatori logici:
{{"op": "and", "args": [<nodo>, <nodo>, ...]}} // >=2 nodi
{{"op": "or", "args": [<nodo>, <nodo>, ...]}} // >=2 nodi
{{"op": "not", "args": [<nodo>]}} // 1 nodo
Le regole sono valutate in ordine; la prima che matcha vince per ogni timestamp.
La default action se nessuna regola matcha è 'flat'.
Comparatori (ritornano boolean series):
{{"op": "gt", "args": [<a>, <b>]}} // a > b
{{"op": "lt", "args": [<a>, <b>]}} // a < b
{{"op": "eq", "args": [<a>, <b>]}} // a == b
Rispondi SOLO con la S-expression in un fence ```lisp ... ```, senza prosa,
senza spiegazioni. Esempio formato:
Crossover (eventi su 2 serie):
{{"op": "crossover", "args": [<serie_a>, <serie_b>]}}
{{"op": "crossunder", "args": [<serie_a>, <serie_b>]}}
```lisp
(strategy
(when (gt (indicator rsi 14) 70.0) (entry-short))
(when (lt (indicator rsi 14) 30.0) (entry-long))
(when (crossover (feature close) (indicator sma 50)) (entry-long)))
Leaf - indicatori (calcolati su close):
{{"kind": "indicator", "name": "sma", "params": [<length>]}}
{{"kind": "indicator", "name": "rsi", "params": [<length>]}}
{{"kind": "indicator", "name": "atr", "params": [<length>]}}
{{"kind": "indicator", "name": "realized_vol", "params": [<window>]}}
{{"kind": "indicator", "name": "macd", "params": [<fast>, <slow>, <signal>]}}
// 0-3 numeri (tutti opzionali con default 12, 26, 9)
Leaf - feature OHLCV:
{{"kind": "feature", "name": "open|high|low|close|volume"}}
Leaf - letterale numerico:
{{"kind": "literal", "value": 70.0}}
VINCOLI
- Gli indicator NON sono annidabili: 'params' accetta solo numeri, mai altri nodi.
- Le regole sono valutate in ordine; la prima che matcha vince per ogni timestamp.
- Default action se nessuna regola matcha = flat.
- 'op' e 'kind' sono mutuamente esclusivi sullo stesso nodo.
Rispondi SOLO con il fence ```json...``` contenente l'oggetto strategy.
Esempio:
```json
{{
"rules": [
{{
"condition": {{"op": "gt", "args": [
{{"kind": "indicator", "name": "rsi", "params": [14]}},
{{"kind": "literal", "value": 70.0}}
]}},
"action": "entry-short"
}},
{{
"condition": {{"op": "lt", "args": [
{{"kind": "indicator", "name": "rsi", "params": [14]}},
{{"kind": "literal", "value": 30.0}}
]}},
"action": "entry-long"
}}
]
}}
```
"""
@@ -79,7 +113,7 @@ USER_TEMPLATE = """\
Mercato: {symbol} timeframe {timeframe}, {n_bars} barre osservate.
Statistiche return: mean={return_mean:.5f}, std={return_std:.5f}, \
skew={skew:.3f}, kurt={kurtosis:.3f}.
Regime volatilità: {volatility_regime}.
Regime volatilità : {volatility_regime}.
Feature accessibili dal tuo genoma: {feature_access}.
Lookback massimo che puoi usare nel ragionamento: {lookback_window} barre.
@@ -88,19 +122,57 @@ Genera una strategia che cerchi anomalie sfruttabili in questo regime.
"""
_SEXP_FENCE_RE = re.compile(
r"```(?:lisp|scheme|sexp)?\s*(\(strategy[\s\S]*?\))\s*```",
_JSON_FENCE_RE = re.compile(
r"```(?:json)?\s*(\{[\s\S]*\})\s*```",
re.MULTILINE,
)
def _extract_sexp(text: str) -> str | None:
m = _SEXP_FENCE_RE.search(text)
def _balance_braces(s: str) -> str | None:
"""Ritorna il prefix di ``s`` che chiude la prima ``{`` con bilanciamento.
Usato come fallback quando l'LLM ritorna JSON top-level senza fence ma
seguito da prosa: troviamo dove finisce il primo oggetto e tagliamo.
"""
if not s.startswith("{"):
return None
depth = 0
in_string = False
escape = False
for i, ch in enumerate(s):
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return s[: i + 1]
return None
def _extract_json(text: str) -> str | None:
"""Estrai un oggetto JSON dal testo del completion.
Strategie di estrazione, in ordine:
1. Fence ```json...``` (greedy: cattura fino all'ultimo ``}`` prima della
chiusura del fence).
2. Testo che inizia direttamente con ``{`` (dopo strip), bilanciato a
livello di parentesi graffe.
"""
m = _JSON_FENCE_RE.search(text)
if m:
return m.group(1)
if text.strip().startswith("(strategy"):
return text.strip()
return None
stripped = text.strip()
return _balance_braces(stripped)
class HypothesisAgent:
@@ -131,16 +203,16 @@ class HypothesisAgent:
completion = self._llm.complete(genome, system=system, user=user)
sexp = _extract_sexp(completion.text)
if sexp is None:
payload = _extract_json(completion.text)
if payload is None:
return HypothesisProposal(
strategy=None,
raw_text=completion.text,
completion=completion,
parse_error="no s-expression found in output",
parse_error="no JSON object found in output",
)
try:
ast = parse_strategy(sexp)
ast = parse_strategy(payload)
validate_strategy(ast)
return HypothesisProposal(
strategy=ast,