- Infra: Freqtrade (futures dry-run) + Redis + dashboard + Docker Compose - Couche IA: ai_analyzer (Claude via abonnement, MCP TradingView, backfill biais) - Stratégies: SampleStrategy, AiBiasStrategy, IchimokuLS (long/short, validée train/test + données vierges + walk-forward), MTFIchimoku, variantes hyperopt - Arbitrage CEX (dry-run), backtesting, walk-forward, volatility targeting - IchimokuLS en dry-run live (config_live.json) Claude-Session: https://claude.ai/code/session_01VHETcFacdnDhQzthLpdYFR
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
"""Client Claude headless via le CLI `claude` (authentifié par l'abonnement).
|
|
|
|
On n'utilise PAS le SDK `anthropic` ni de clé API. On invoque `claude -p` en
|
|
sous-processus avec sortie structurée (`--json-schema`), ce qui consomme l'abonnement
|
|
Claude Code de l'utilisateur (pas de facturation au token).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from market_data import PairSnapshot
|
|
from models import MarketBias, MarketBiasBatch
|
|
|
|
_HERE = Path(__file__).resolve().parent
|
|
_SYSTEM_PROMPT_FILE = _HERE / "system_prompt.md"
|
|
|
|
|
|
class ClaudeError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class ClaudeClient:
|
|
def __init__(
|
|
self,
|
|
model: str = "claude-sonnet-4-6",
|
|
max_turns: int = 6,
|
|
mcp_config: Optional[str] = None,
|
|
allowed_tools: Optional[str] = None,
|
|
timeout_s: int = 180,
|
|
) -> None:
|
|
self.model = model
|
|
self.max_turns = max_turns
|
|
# .mcp.json à la racine projet, chargé seulement si présent.
|
|
self.mcp_config = mcp_config
|
|
# Ex. "mcp__tradingview" pour autoriser uniquement les outils du serveur MCP.
|
|
self.allowed_tools = allowed_tools
|
|
self.timeout_s = timeout_s
|
|
self._guard_no_api_key()
|
|
|
|
@staticmethod
|
|
def _guard_no_api_key() -> None:
|
|
# ANTHROPIC_API_KEY ferait basculer Claude sur la facturation au token.
|
|
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
raise ClaudeError(
|
|
"ANTHROPIC_API_KEY est défini : on veut l'abonnement, pas l'API. "
|
|
"Retire cette variable (utilise CLAUDE_CODE_OAUTH_TOKEN)."
|
|
)
|
|
|
|
def _build_prompt(self, snapshots: list[PairSnapshot]) -> str:
|
|
payload = {
|
|
"instruction": "Produis un biais directionnel pour chaque paire ci-dessous.",
|
|
"market_snapshot": [s.to_dict() for s in snapshots],
|
|
"pairs": [s.pair for s in snapshots],
|
|
}
|
|
return (
|
|
"Voici l'instantané de marché (JSON). Si le MCP TradingView est disponible, "
|
|
"tu peux l'utiliser pour confirmer (RSI/MACD/Bollinger).\n\n"
|
|
+ json.dumps(payload, ensure_ascii=False)
|
|
)
|
|
|
|
def get_biases(self, snapshots: list[PairSnapshot]) -> MarketBiasBatch:
|
|
if not snapshots:
|
|
return MarketBiasBatch(biases=[])
|
|
|
|
system_prompt = (
|
|
_SYSTEM_PROMPT_FILE.read_text(encoding="utf-8")
|
|
if _SYSTEM_PROMPT_FILE.exists()
|
|
else ""
|
|
)
|
|
|
|
cmd = [
|
|
"claude",
|
|
"-p",
|
|
self._build_prompt(snapshots),
|
|
"--model",
|
|
self.model,
|
|
"--output-format",
|
|
"json",
|
|
"--json-schema",
|
|
json.dumps(MarketBiasBatch.json_schema()),
|
|
"--max-turns",
|
|
str(self.max_turns),
|
|
]
|
|
if system_prompt:
|
|
cmd += ["--append-system-prompt", system_prompt]
|
|
if self.mcp_config:
|
|
cmd += ["--mcp-config", self.mcp_config, "--strict-mcp-config"]
|
|
if self.allowed_tools:
|
|
cmd += ["--allowedTools", self.allowed_tools]
|
|
else:
|
|
# Pas de MCP : on coupe les outils intégrés (analyse pure sur l'instantané).
|
|
cmd += [
|
|
"--disallowedTools",
|
|
"Bash,Read,Write,Edit,WebSearch,WebFetch,Glob,Grep,Task",
|
|
]
|
|
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=self.timeout_s,
|
|
env=os.environ.copy(),
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise ClaudeError(f"claude -p a dépassé {self.timeout_s}s") from exc
|
|
|
|
if proc.returncode != 0:
|
|
raise ClaudeError(
|
|
f"claude -p code {proc.returncode}: {proc.stderr.strip()[:500]}"
|
|
)
|
|
|
|
return self._parse(proc.stdout)
|
|
|
|
@staticmethod
|
|
def _parse(stdout: str) -> MarketBiasBatch:
|
|
try:
|
|
envelope = json.loads(stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise ClaudeError(f"Sortie claude non-JSON: {stdout[:300]}") from exc
|
|
|
|
if envelope.get("is_error"):
|
|
raise ClaudeError(
|
|
f"claude erreur ({envelope.get('subtype')}): "
|
|
f"{envelope.get('errors') or envelope.get('result')}"
|
|
)
|
|
|
|
# Préférence : sortie structurée validée par le schéma.
|
|
structured = envelope.get("structured_output")
|
|
if isinstance(structured, dict):
|
|
return MarketBiasBatch.model_validate(structured)
|
|
|
|
# Repli : extraire un objet JSON du champ `result`.
|
|
result = envelope.get("result", "")
|
|
obj = _extract_json_object(result)
|
|
if obj is not None:
|
|
return MarketBiasBatch.model_validate(obj)
|
|
|
|
raise ClaudeError(f"Pas de sortie structurée exploitable: {result[:300]}")
|
|
|
|
|
|
def _extract_json_object(text: str) -> Optional[dict]:
|
|
start = text.find("{")
|
|
end = text.rfind("}")
|
|
if start == -1 or end == -1 or end <= start:
|
|
return None
|
|
try:
|
|
return json.loads(text[start : end + 1])
|
|
except json.JSONDecodeError:
|
|
return None
|