Mehdi commited on
Commit
68025ee
·
0 Parent(s):

feat: backend

Browse files
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy source
10
+ COPY . .
11
+
12
+ # Create data directory for SQLite
13
+ RUN mkdir -p /app/data
14
+
15
+ # HuggingFace Spaces uses port 7860
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CryptoAgentBench API
3
+ emoji: 📈
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # CryptoAgentBench API
12
+
13
+ Benchmarks open-source LLMs as crypto trading agents.
14
+
15
+ ## Endpoints
16
+
17
+ - `GET /` — health check
18
+ - `GET /models` — list available free-tier LLMs
19
+ - `GET /benchmarks` — describe A/B/C benchmarks
20
+ - `POST /backtest` — launch a backtest run
21
+ - `GET /runs/{run_id}` — get run details + equity curve
22
+ - `GET /runs/{run_id}/decisions` — get decision log
23
+ - `GET /leaderboard` — all completed runs sorted by date
24
+
25
+ ## Environment Variables
26
+
27
+ Set in HuggingFace Space Secrets:
28
+ - `OPENROUTER_API_KEY` — required
29
+ - `CRYPTOPANIC_API_KEY` — optional (news)
30
+ - `CRYPTOCOMPARE_API_KEY` — optional (news fallback)
agents/__init__.py ADDED
File without changes
agents/analysts.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.base import Agent
2
+ from llm.prompts import (
3
+ TECHNICAL_ANALYST_SYSTEM,
4
+ NEWS_ANALYST_SYSTEM,
5
+ SENTIMENT_ANALYST_SYSTEM,
6
+ build_technical_analyst_prompt,
7
+ build_news_analyst_prompt,
8
+ build_sentiment_analyst_prompt,
9
+ )
10
+
11
+
12
+ class TechnicalAnalyst(Agent):
13
+ def __init__(self, llm_client):
14
+ super().__init__("TechnicalAnalyst", TECHNICAL_ANALYST_SYSTEM, llm_client)
15
+
16
+ def build_prompt(self, context: dict) -> str:
17
+ return build_technical_analyst_prompt(context)
18
+
19
+ def parse(self, raw: str) -> dict:
20
+ result = super().parse(raw)
21
+ signal = result.get("signal", "NEUTRAL").upper()
22
+ if signal not in ("BULLISH", "BEARISH", "NEUTRAL"):
23
+ signal = "NEUTRAL"
24
+ return {
25
+ "signal": signal,
26
+ "strength": float(result.get("strength", 0.5)),
27
+ "key_levels": result.get("key_levels", {}),
28
+ "summary": str(result.get("summary", "")),
29
+ }
30
+
31
+
32
+ class NewsAnalyst(Agent):
33
+ def __init__(self, llm_client):
34
+ super().__init__("NewsAnalyst", NEWS_ANALYST_SYSTEM, llm_client)
35
+
36
+ def build_prompt(self, context: dict) -> str:
37
+ return build_news_analyst_prompt(
38
+ context.get("news", []),
39
+ context.get("asset", "BTC/USDT"),
40
+ )
41
+
42
+ def parse(self, raw: str) -> dict:
43
+ result = super().parse(raw)
44
+ sentiment = result.get("sentiment", "NEUTRAL").upper()
45
+ if sentiment not in ("POSITIVE", "NEGATIVE", "NEUTRAL"):
46
+ sentiment = "NEUTRAL"
47
+ return {
48
+ "sentiment": sentiment,
49
+ "score": float(result.get("score", 0.0)),
50
+ "key_themes": result.get("key_themes", []),
51
+ "summary": str(result.get("summary", "")),
52
+ }
53
+
54
+
55
+ class SentimentAnalyst(Agent):
56
+ def __init__(self, llm_client):
57
+ super().__init__("SentimentAnalyst", SENTIMENT_ANALYST_SYSTEM, llm_client)
58
+
59
+ def build_prompt(self, context: dict) -> str:
60
+ return build_sentiment_analyst_prompt(
61
+ context.get("onchain", {}),
62
+ context.get("asset", "BTC/USDT"),
63
+ )
64
+
65
+ def parse(self, raw: str) -> dict:
66
+ result = super().parse(raw)
67
+ return {
68
+ "sentiment": str(result.get("sentiment", "NEUTRAL")),
69
+ "score": float(result.get("score", 0.0)),
70
+ "funding_bias": str(result.get("funding_bias", "NEUTRAL")),
71
+ "summary": str(result.get("summary", "")),
72
+ }
agents/base.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ import logging
4
+ from llm.openrouter import OpenRouterClient
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ FALLBACK_DECISION = {"action": "HOLD", "size": 0.0, "confidence": 0.0, "reason": "Parse error — defaulting to HOLD"}
9
+
10
+
11
+ class Agent:
12
+ def __init__(self, role: str, system_prompt: str, llm_client: OpenRouterClient):
13
+ self.role = role
14
+ self.system_prompt = system_prompt
15
+ self.llm = llm_client
16
+
17
+ def run(self, context: dict) -> dict:
18
+ prompt = self.build_prompt(context)
19
+ raw = self.llm.call(self.system_prompt, prompt)
20
+ return self.parse(raw)
21
+
22
+ def build_prompt(self, context: dict) -> str:
23
+ raise NotImplementedError
24
+
25
+ def parse(self, raw: str) -> dict:
26
+ """Robust JSON parsing: strip markdown fences, regex fallback."""
27
+ if not raw:
28
+ return FALLBACK_DECISION.copy()
29
+
30
+ # Strip markdown code fences
31
+ cleaned = re.sub(r"```(?:json)?\s*", "", raw).strip()
32
+ cleaned = re.sub(r"```\s*$", "", cleaned).strip()
33
+
34
+ # Try direct parse
35
+ try:
36
+ return json.loads(cleaned)
37
+ except json.JSONDecodeError:
38
+ pass
39
+
40
+ # Try to extract first JSON object
41
+ match = re.search(r"\{[^{}]*\}", cleaned, re.DOTALL)
42
+ if match:
43
+ try:
44
+ return json.loads(match.group())
45
+ except json.JSONDecodeError:
46
+ pass
47
+
48
+ logger.warning(f"[{self.role}] Failed to parse response: {raw[:200]}")
49
+ return FALLBACK_DECISION.copy()
agents/pipeline.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from agents.trader import Trader
3
+ from agents.analysts import TechnicalAnalyst, NewsAnalyst, SentimentAnalyst
4
+ from agents.researcher import Researcher
5
+ from agents.risk_manager import RiskManager
6
+ from llm.openrouter import OpenRouterClient
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class SingleAgentPipeline:
12
+ """Benchmark A: single Trader agent with price + indicators."""
13
+
14
+ def __init__(self, trader: Trader):
15
+ self.trader = trader
16
+
17
+ def decide(self, market_data: dict) -> dict:
18
+ decision = self.trader.run(market_data)
19
+ return {
20
+ "decision": decision,
21
+ "agent_outputs": {"trader": decision},
22
+ }
23
+
24
+
25
+ class SimplePipeline:
26
+ """Benchmark B: TechnicalAnalyst + NewsAnalyst -> Trader."""
27
+
28
+ def __init__(self, analysts: list, trader: Trader):
29
+ self.technical = next(a for a in analysts if isinstance(a, TechnicalAnalyst))
30
+ self.news = next(a for a in analysts if isinstance(a, NewsAnalyst))
31
+ self.trader = trader
32
+
33
+ def decide(self, market_data: dict) -> dict:
34
+ tech_analysis = self.technical.run(market_data)
35
+ news_analysis = self.news.run(market_data)
36
+
37
+ trader_context = {
38
+ **market_data,
39
+ "tech_analysis": tech_analysis,
40
+ "news_analysis": news_analysis,
41
+ }
42
+ decision = self.trader.run(trader_context)
43
+
44
+ return {
45
+ "decision": decision,
46
+ "agent_outputs": {
47
+ "technical_analyst": tech_analysis,
48
+ "news_analyst": news_analysis,
49
+ "trader": decision,
50
+ },
51
+ }
52
+
53
+
54
+ class FullPipeline:
55
+ """Benchmark C: Technical + Sentiment + News -> Researcher -> RiskManager -> Trader."""
56
+
57
+ def __init__(self, analysts: list, researcher: Researcher, risk_manager: RiskManager, trader: Trader):
58
+ self.technical = next(a for a in analysts if isinstance(a, TechnicalAnalyst))
59
+ self.sentiment = next(a for a in analysts if isinstance(a, SentimentAnalyst))
60
+ self.news = next(a for a in analysts if isinstance(a, NewsAnalyst))
61
+ self.researcher = researcher
62
+ self.risk_manager = risk_manager
63
+ self.trader = trader
64
+
65
+ def decide(self, market_data: dict) -> dict:
66
+ # Phase 1: analysts
67
+ tech_analysis = self.technical.run(market_data)
68
+ news_analysis = self.news.run(market_data)
69
+ sentiment_analysis = self.sentiment.run(market_data)
70
+
71
+ # Phase 2: researcher bull/bear debate
72
+ research_context = {
73
+ **market_data,
74
+ "tech_analysis": tech_analysis,
75
+ "news_analysis": news_analysis,
76
+ "sentiment_analysis": sentiment_analysis,
77
+ }
78
+ research = self.researcher.run(research_context)
79
+
80
+ # Phase 3: risk manager
81
+ portfolio = market_data.get("portfolio", {})
82
+ risk_context = {
83
+ "recommendation": research,
84
+ "portfolio": portfolio,
85
+ }
86
+ risk_decision = self.risk_manager.run(risk_context)
87
+
88
+ # Phase 4: final trader decision
89
+ trader_context = {
90
+ **market_data,
91
+ "research": research,
92
+ "risk_decision": risk_decision,
93
+ }
94
+ decision = self.trader.run(trader_context)
95
+
96
+ return {
97
+ "decision": decision,
98
+ "agent_outputs": {
99
+ "technical_analyst": tech_analysis,
100
+ "news_analyst": news_analysis,
101
+ "sentiment_analyst": sentiment_analysis,
102
+ "researcher": research,
103
+ "risk_manager": risk_decision,
104
+ "trader": decision,
105
+ },
106
+ }
107
+
108
+
109
+ def build_pipeline(benchmark: str, model: str):
110
+ """Factory: build the correct pipeline for benchmark A/B/C."""
111
+ llm = OpenRouterClient(model=model)
112
+
113
+ if benchmark == "A":
114
+ return SingleAgentPipeline(trader=Trader(llm, benchmark="A"))
115
+
116
+ if benchmark == "B":
117
+ return SimplePipeline(
118
+ analysts=[TechnicalAnalyst(llm), NewsAnalyst(llm)],
119
+ trader=Trader(llm, benchmark="B"),
120
+ )
121
+
122
+ if benchmark == "C":
123
+ return FullPipeline(
124
+ analysts=[TechnicalAnalyst(llm), SentimentAnalyst(llm), NewsAnalyst(llm)],
125
+ researcher=Researcher(llm),
126
+ risk_manager=RiskManager(llm),
127
+ trader=Trader(llm, benchmark="C"),
128
+ )
129
+
130
+ raise ValueError(f"Unknown benchmark: {benchmark}")
agents/researcher.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.base import Agent
2
+ from llm.prompts import RESEARCHER_SYSTEM, build_researcher_prompt
3
+
4
+
5
+ class Researcher(Agent):
6
+ def __init__(self, llm_client):
7
+ super().__init__("Researcher", RESEARCHER_SYSTEM, llm_client)
8
+
9
+ def build_prompt(self, context: dict) -> str:
10
+ return build_researcher_prompt(
11
+ context.get("tech_analysis", {}),
12
+ context.get("news_analysis", {}),
13
+ context.get("sentiment_analysis", {}),
14
+ context.get("asset", "BTC/USDT"),
15
+ )
16
+
17
+ def parse(self, raw: str) -> dict:
18
+ result = super().parse(raw)
19
+ verdict = result.get("verdict", "NEUTRAL").upper()
20
+ if verdict not in ("BULLISH", "BEARISH", "NEUTRAL"):
21
+ verdict = "NEUTRAL"
22
+ return {
23
+ "verdict": verdict,
24
+ "conviction": float(result.get("conviction", 0.5)),
25
+ "bull_points": result.get("bull_points", []),
26
+ "bear_points": result.get("bear_points", []),
27
+ "synthesis": str(result.get("synthesis", "")),
28
+ }
agents/risk_manager.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.base import Agent
2
+ from llm.prompts import RISK_MANAGER_SYSTEM, build_risk_manager_prompt
3
+
4
+
5
+ class RiskManager(Agent):
6
+ def __init__(self, llm_client):
7
+ super().__init__("RiskManager", RISK_MANAGER_SYSTEM, llm_client)
8
+
9
+ def build_prompt(self, context: dict) -> str:
10
+ return build_risk_manager_prompt(
11
+ context.get("recommendation", {}),
12
+ context.get("portfolio", {}),
13
+ )
14
+
15
+ def parse(self, raw: str) -> dict:
16
+ result = super().parse(raw)
17
+ action = result.get("adjusted_action", "HOLD").upper()
18
+ if action not in ("BUY", "SELL", "HOLD"):
19
+ action = "HOLD"
20
+ return {
21
+ "approved": bool(result.get("approved", True)),
22
+ "adjusted_action": action,
23
+ "adjusted_size": float(result.get("adjusted_size", 0.5)),
24
+ "risk_note": str(result.get("risk_note", "")),
25
+ }
agents/trader.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.base import Agent, FALLBACK_DECISION
2
+ from llm.prompts import (
3
+ TRADER_SYSTEM,
4
+ build_trader_prompt_A,
5
+ build_trader_prompt_B,
6
+ build_trader_prompt_C,
7
+ )
8
+
9
+
10
+ class Trader(Agent):
11
+ def __init__(self, llm_client, benchmark: str = "A"):
12
+ super().__init__("Trader", TRADER_SYSTEM, llm_client)
13
+ self.benchmark = benchmark
14
+
15
+ def build_prompt(self, context: dict) -> str:
16
+ if self.benchmark == "A":
17
+ return build_trader_prompt_A(context)
18
+ if self.benchmark == "B":
19
+ return build_trader_prompt_B(
20
+ context.get("tech_analysis", {}),
21
+ context.get("news_analysis", {}),
22
+ context.get("portfolio", {}),
23
+ context.get("asset", "BTC/USDT"),
24
+ context.get("current_price", 0),
25
+ )
26
+ if self.benchmark == "C":
27
+ return build_trader_prompt_C(
28
+ context.get("research", {}),
29
+ context.get("risk_decision", {}),
30
+ context.get("portfolio", {}),
31
+ context.get("asset", "BTC/USDT"),
32
+ context.get("current_price", 0),
33
+ )
34
+ return build_trader_prompt_A(context)
35
+
36
+ def parse(self, raw: str) -> dict:
37
+ result = super().parse(raw)
38
+ # Validate and normalize
39
+ action = result.get("action", "HOLD").upper()
40
+ if action not in ("BUY", "SELL", "HOLD"):
41
+ action = "HOLD"
42
+ size = float(result.get("size", 0.5))
43
+ size = max(0.0, min(1.0, size))
44
+ confidence = float(result.get("confidence", 0.5))
45
+ confidence = max(0.0, min(1.0, confidence))
46
+ return {
47
+ "action": action,
48
+ "size": size,
49
+ "confidence": confidence,
50
+ "reason": str(result.get("reason", "")),
51
+ }
app.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+ from datetime import date
6
+
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+
9
+ from fastapi import FastAPI, BackgroundTasks, HTTPException
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+ from typing import Optional
13
+
14
+ from config import BENCHMARKS, FREE_MODELS, ASSETS
15
+ from db.store import init_db, create_run, complete_run, fail_run, get_run, get_leaderboard, get_decisions
16
+ from backtest.runner import run_backtest
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s %(levelname)s %(name)s — %(message)s",
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @asynccontextmanager
26
+ async def lifespan(app: FastAPI):
27
+ init_db()
28
+ logger.info("CryptoAgentBench API started")
29
+ yield
30
+
31
+
32
+ app = FastAPI(
33
+ title="CryptoAgentBench API",
34
+ description="Benchmark open-source LLMs as crypto trading agents",
35
+ version="1.0.0",
36
+ lifespan=lifespan,
37
+ )
38
+
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=["*"],
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+
48
+ # ── Schemas ──────────────────────────────────────────────────────────────────
49
+
50
+ class BacktestRequest(BaseModel):
51
+ benchmark: str = Field(..., description="A, B, or C")
52
+ model: str = Field(default="meta-llama/llama-3.3-70b-instruct:free")
53
+ asset: str = Field(default="BTC/USDT")
54
+ start_date: str = Field(default="2024-01-01", description="YYYY-MM-DD")
55
+ end_date: str = Field(default="2024-06-30", description="YYYY-MM-DD")
56
+
57
+ def validate_fields(self):
58
+ if self.benchmark not in BENCHMARKS:
59
+ raise ValueError(f"benchmark must be one of {BENCHMARKS}")
60
+ if self.asset not in ASSETS:
61
+ raise ValueError(f"asset must be one of {ASSETS}")
62
+
63
+
64
+ # ── Background task ───────────────────────────────────────────────────────────
65
+
66
+ def _run_backtest_task(run_id: str, req: BacktestRequest):
67
+ try:
68
+ result = run_backtest(
69
+ benchmark=req.benchmark,
70
+ model=req.model,
71
+ asset=req.asset,
72
+ start_date=req.start_date,
73
+ end_date=req.end_date,
74
+ )
75
+ complete_run(run_id, result)
76
+ logger.info(f"Run {run_id} completed. CR={result['metrics'].get('cumulative_return')}")
77
+ except Exception as e:
78
+ logger.error(f"Run {run_id} failed: {e}", exc_info=True)
79
+ fail_run(run_id, str(e))
80
+
81
+
82
+ # ── Routes ────────────────────────────────────────────────────────────────────
83
+
84
+ @app.get("/")
85
+ def health():
86
+ return {
87
+ "status": "ok",
88
+ "service": "CryptoAgentBench API",
89
+ "version": "1.0.0",
90
+ "date": date.today().isoformat(),
91
+ }
92
+
93
+
94
+ @app.get("/models")
95
+ def list_models():
96
+ return {
97
+ "models": FREE_MODELS,
98
+ "note": "All models are free-tier via OpenRouter",
99
+ }
100
+
101
+
102
+ @app.get("/benchmarks")
103
+ def list_benchmarks():
104
+ return {
105
+ "benchmarks": {
106
+ "A": {
107
+ "name": "Baseline",
108
+ "description": "Single agent: LLM sees price + indicators directly",
109
+ "agents": ["Trader"],
110
+ "data": ["OHLCV", "Technical Indicators"],
111
+ },
112
+ "B": {
113
+ "name": "Intermediate",
114
+ "description": "Technical Analyst + News Analyst -> Trader",
115
+ "agents": ["TechnicalAnalyst", "NewsAnalyst", "Trader"],
116
+ "data": ["OHLCV", "Technical Indicators", "News"],
117
+ },
118
+ "C": {
119
+ "name": "Full Multi-Agent",
120
+ "description": "Technical + Sentiment + News -> Researcher (bull/bear debate) -> Risk Manager -> Trader",
121
+ "agents": ["TechnicalAnalyst", "SentimentAnalyst", "NewsAnalyst", "Researcher", "RiskManager", "Trader"],
122
+ "data": ["OHLCV", "Technical Indicators", "News", "Fear & Greed", "Funding Rates"],
123
+ },
124
+ }
125
+ }
126
+
127
+
128
+ @app.post("/backtest")
129
+ def start_backtest(req: BacktestRequest, background_tasks: BackgroundTasks):
130
+ try:
131
+ req.validate_fields()
132
+ except ValueError as e:
133
+ raise HTTPException(status_code=422, detail=str(e))
134
+
135
+ run_id = create_run(req.benchmark, req.model, req.asset, req.start_date, req.end_date)
136
+ background_tasks.add_task(_run_backtest_task, run_id, req)
137
+
138
+ return {
139
+ "run_id": run_id,
140
+ "status": "running",
141
+ "message": "Backtest started. Poll /runs/{run_id} for results.",
142
+ }
143
+
144
+
145
+ @app.get("/runs/{run_id}")
146
+ def get_run_detail(run_id: str):
147
+ run = get_run(run_id)
148
+ if not run:
149
+ raise HTTPException(status_code=404, detail="Run not found")
150
+ # Don't embed all decisions in the detail view
151
+ run_out = {k: v for k, v in run.items() if k not in ("equity_curve", "hodl_curve")}
152
+ run_out["equity_curve"] = run.get("equity_curve", [])
153
+ run_out["hodl_curve"] = run.get("hodl_curve", [])
154
+ return run_out
155
+
156
+
157
+ @app.get("/runs/{run_id}/decisions")
158
+ def get_run_decisions(run_id: str):
159
+ run = get_run(run_id)
160
+ if not run:
161
+ raise HTTPException(status_code=404, detail="Run not found")
162
+ decisions = get_decisions(run_id)
163
+ return {"run_id": run_id, "decisions": decisions}
164
+
165
+
166
+ @app.get("/leaderboard")
167
+ def leaderboard():
168
+ runs = get_leaderboard()
169
+ board = []
170
+ for r in runs:
171
+ metrics = r.get("metrics", {}) or {}
172
+ board.append({
173
+ "run_id": r["id"],
174
+ "benchmark": r["benchmark"],
175
+ "model": r["model"],
176
+ "asset": r["asset"],
177
+ "start_date": r.get("start_date"),
178
+ "end_date": r.get("end_date"),
179
+ "cumulative_return": metrics.get("cumulative_return"),
180
+ "sharpe_ratio": metrics.get("sharpe_ratio"),
181
+ "sortino_ratio": metrics.get("sortino_ratio"),
182
+ "max_drawdown": metrics.get("max_drawdown"),
183
+ "win_rate": metrics.get("win_rate"),
184
+ "num_trades": metrics.get("num_trades"),
185
+ "hodl_return": metrics.get("hodl_return"),
186
+ "alpha": metrics.get("alpha"),
187
+ "final_value": metrics.get("final_value"),
188
+ "completed_at": r.get("completed_at"),
189
+ })
190
+ return {"leaderboard": board, "total": len(board)}
backtest/__init__.py ADDED
File without changes
backtest/portfolio.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from config import INITIAL_CAPITAL, TRADE_FEE
3
+
4
+
5
+ class Portfolio:
6
+ def __init__(self, initial_capital: float = INITIAL_CAPITAL):
7
+ self.initial_capital = initial_capital
8
+ self.cash = initial_capital
9
+ self.position = 0.0 # units of asset held
10
+ self.trades = []
11
+ self.equity_history = [] # [{date, value, price, action}]
12
+ self.peak_value = initial_capital
13
+
14
+ def apply_decision(self, decision: dict, price: float, date: str):
15
+ action = decision.get("action", "HOLD")
16
+ size = float(decision.get("size", 0.5))
17
+ size = max(0.0, min(1.0, size))
18
+
19
+ trade_executed = False
20
+ trade_value = 0.0
21
+
22
+ if action == "BUY" and self.cash > 0:
23
+ spend = self.cash * size
24
+ fee = spend * TRADE_FEE
25
+ net_spend = spend - fee
26
+ units = net_spend / price
27
+ self.position += units
28
+ self.cash -= spend
29
+ trade_executed = True
30
+ trade_value = spend
31
+ self.trades.append({
32
+ "date": date,
33
+ "action": "BUY",
34
+ "price": price,
35
+ "units": units,
36
+ "value": spend,
37
+ "fee": fee,
38
+ })
39
+
40
+ elif action == "SELL" and self.position > 0:
41
+ sell_units = self.position * size
42
+ gross = sell_units * price
43
+ fee = gross * TRADE_FEE
44
+ net = gross - fee
45
+ self.position -= sell_units
46
+ self.cash += net
47
+ trade_executed = True
48
+ trade_value = gross
49
+ self.trades.append({
50
+ "date": date,
51
+ "action": "SELL",
52
+ "price": price,
53
+ "units": sell_units,
54
+ "value": gross,
55
+ "fee": fee,
56
+ })
57
+
58
+ total_value = self.cash + self.position * price
59
+ self.peak_value = max(self.peak_value, total_value)
60
+
61
+ self.equity_history.append({
62
+ "date": date,
63
+ "value": round(total_value, 2),
64
+ "cash": round(self.cash, 2),
65
+ "position": self.position,
66
+ "price": price,
67
+ "action": action,
68
+ "trade_executed": trade_executed,
69
+ "trade_value": round(trade_value, 2),
70
+ })
71
+
72
+ def current_value(self, price: float) -> float:
73
+ return self.cash + self.position * price
74
+
75
+ def drawdown(self, price: float) -> float:
76
+ current = self.current_value(price)
77
+ if self.peak_value == 0:
78
+ return 0.0
79
+ return (self.peak_value - current) / self.peak_value
80
+
81
+ def snapshot(self, price: float) -> dict:
82
+ total = self.current_value(price)
83
+ return {
84
+ "cash": round(self.cash, 2),
85
+ "position": self.position,
86
+ "total_value": round(total, 2),
87
+ "drawdown": round(self.drawdown(price), 4),
88
+ }
89
+
90
+
91
+ def compute_metrics(equity_history: list, initial_capital: float, hodl_final: float) -> dict:
92
+ if not equity_history:
93
+ return {}
94
+
95
+ values = [e["value"] for e in equity_history]
96
+ dates = [e["date"] for e in equity_history]
97
+
98
+ # Daily returns
99
+ returns = []
100
+ for i in range(1, len(values)):
101
+ r = (values[i] - values[i - 1]) / values[i - 1] if values[i - 1] != 0 else 0
102
+ returns.append(r)
103
+
104
+ returns_arr = np.array(returns)
105
+ final_value = values[-1]
106
+
107
+ # Cumulative Return
108
+ cumulative_return = (final_value - initial_capital) / initial_capital
109
+
110
+ # Sharpe Ratio (annualized, risk-free = 0)
111
+ if len(returns_arr) > 1 and returns_arr.std() > 0:
112
+ sharpe = (returns_arr.mean() / returns_arr.std()) * np.sqrt(252)
113
+ else:
114
+ sharpe = 0.0
115
+
116
+ # Sortino Ratio
117
+ downside = returns_arr[returns_arr < 0]
118
+ if len(downside) > 0 and downside.std() > 0:
119
+ sortino = (returns_arr.mean() / downside.std()) * np.sqrt(252)
120
+ else:
121
+ sortino = 0.0
122
+
123
+ # Max Drawdown
124
+ peak = initial_capital
125
+ max_dd = 0.0
126
+ for v in values:
127
+ if v > peak:
128
+ peak = v
129
+ dd = (peak - v) / peak if peak > 0 else 0
130
+ max_dd = max(max_dd, dd)
131
+
132
+ # Win Rate
133
+ winning_trades = sum(1 for e in equity_history if e.get("trade_executed") and e.get("action") == "BUY")
134
+ num_trades = sum(1 for e in equity_history if e.get("trade_executed"))
135
+ win_rate = winning_trades / num_trades if num_trades > 0 else 0.0
136
+
137
+ # vs HODL
138
+ hodl_return = (hodl_final - initial_capital) / initial_capital
139
+ alpha = cumulative_return - hodl_return
140
+
141
+ return {
142
+ "cumulative_return": round(cumulative_return, 4),
143
+ "sharpe_ratio": round(sharpe, 4),
144
+ "sortino_ratio": round(sortino, 4),
145
+ "max_drawdown": round(max_dd, 4),
146
+ "win_rate": round(win_rate, 4),
147
+ "num_trades": num_trades,
148
+ "final_value": round(final_value, 2),
149
+ "hodl_return": round(hodl_return, 4),
150
+ "alpha": round(alpha, 4),
151
+ "start_date": dates[0] if dates else "",
152
+ "end_date": dates[-1] if dates else "",
153
+ "num_days": len(values),
154
+ }
backtest/runner.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime
3
+ from data.prices import fetch_ohlcv, ohlcv_to_records
4
+ from data.indicators import compute_indicators, get_latest_indicators
5
+ from data.news import fetch_news
6
+ from data.onchain import fetch_onchain_data
7
+ from backtest.portfolio import Portfolio, compute_metrics
8
+ from agents.pipeline import build_pipeline
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def run_backtest(
14
+ benchmark: str,
15
+ model: str,
16
+ asset: str,
17
+ start_date: str,
18
+ end_date: str,
19
+ progress_callback=None,
20
+ ) -> dict:
21
+ """
22
+ Full backtest loop. Returns dict with metrics, equity_curve, decisions, hodl_curve.
23
+ """
24
+ logger.info(f"Starting backtest: benchmark={benchmark} model={model} asset={asset} {start_date}->{end_date}")
25
+
26
+ # Fetch and prepare price data
27
+ df_raw = fetch_ohlcv(asset, start_date, end_date)
28
+ df = compute_indicators(df_raw)
29
+
30
+ if df.empty or len(df) < 2:
31
+ raise ValueError(f"Insufficient data for {asset} from {start_date} to {end_date}")
32
+
33
+ ohlcv_records = ohlcv_to_records(df)
34
+
35
+ # Portfolio for agent strategy
36
+ portfolio = Portfolio()
37
+
38
+ # HODL portfolio (buy on day 1, hold)
39
+ hodl_portfolio = Portfolio()
40
+ first_price = float(df.iloc[0]["close"])
41
+ hodl_portfolio.apply_decision({"action": "BUY", "size": 1.0}, first_price, str(df.iloc[0]["date"]))
42
+
43
+ decisions_log = []
44
+ pipeline = build_pipeline(benchmark, model)
45
+
46
+ total_days = len(df)
47
+
48
+ for i, row in df.iterrows():
49
+ date = str(row["date"])
50
+ price = float(row["close"])
51
+
52
+ # Build market_data context (data available up to this day)
53
+ recent_records = ohlcv_records[: i + 1]
54
+ indicators = get_latest_indicators(df.iloc[: i + 1])
55
+
56
+ portfolio_snapshot = portfolio.snapshot(price)
57
+
58
+ market_data = {
59
+ "asset": asset,
60
+ "current_price": price,
61
+ "date": date,
62
+ "recent_ohlcv": recent_records[-30:], # last 30 days
63
+ "indicators": indicators,
64
+ "portfolio": portfolio_snapshot,
65
+ }
66
+
67
+ # Add news/onchain for benchmarks B and C (only in live-style; skip for backtest speed)
68
+ if benchmark in ("B", "C"):
69
+ try:
70
+ news = fetch_news(asset, date=date, limit=5)
71
+ market_data["news"] = news
72
+ except Exception as e:
73
+ logger.warning(f"News fetch failed for {date}: {e}")
74
+ market_data["news"] = []
75
+
76
+ if benchmark == "C":
77
+ try:
78
+ onchain = fetch_onchain_data(asset)
79
+ market_data["onchain"] = onchain
80
+ except Exception as e:
81
+ logger.warning(f"On-chain fetch failed for {date}: {e}")
82
+ market_data["onchain"] = {}
83
+
84
+ # Get decision from pipeline
85
+ try:
86
+ result = pipeline.decide(market_data)
87
+ decision = result["decision"]
88
+ agent_outputs = result.get("agent_outputs", {})
89
+ except Exception as e:
90
+ logger.error(f"Pipeline error on {date}: {e}")
91
+ decision = {"action": "HOLD", "size": 0.0, "confidence": 0.0, "reason": f"Error: {e}"}
92
+ agent_outputs = {}
93
+
94
+ # Apply to portfolio
95
+ portfolio.apply_decision(decision, price, date)
96
+
97
+ # Update HODL
98
+ hodl_portfolio.equity_history.append({
99
+ "date": date,
100
+ "value": round(hodl_portfolio.cash + hodl_portfolio.position * price, 2),
101
+ "price": price,
102
+ "action": "HOLD",
103
+ "trade_executed": False,
104
+ "trade_value": 0.0,
105
+ "cash": hodl_portfolio.cash,
106
+ "position": hodl_portfolio.position,
107
+ })
108
+
109
+ decisions_log.append({
110
+ "date": date,
111
+ "price": price,
112
+ "action": decision.get("action"),
113
+ "size": decision.get("size"),
114
+ "confidence": decision.get("confidence"),
115
+ "reason": decision.get("reason"),
116
+ "agent_outputs": agent_outputs,
117
+ "portfolio_value": portfolio_snapshot["total_value"],
118
+ })
119
+
120
+ if progress_callback:
121
+ progress_callback(i + 1, total_days)
122
+
123
+ logger.debug(f"{date} | {asset} | {decision.get('action')} | price={price:.2f} | portfolio={portfolio_snapshot['total_value']:.2f}")
124
+
125
+ # Final metrics
126
+ hodl_final = hodl_portfolio.equity_history[-1]["value"] if hodl_portfolio.equity_history else portfolio.initial_capital
127
+ metrics = compute_metrics(portfolio.equity_history, portfolio.initial_capital, hodl_final)
128
+
129
+ hodl_curve = [{"date": e["date"], "value": e["value"]} for e in hodl_portfolio.equity_history]
130
+ equity_curve = [{"date": e["date"], "value": e["value"], "action": e.get("action", "HOLD")} for e in portfolio.equity_history]
131
+
132
+ return {
133
+ "benchmark": benchmark,
134
+ "model": model,
135
+ "asset": asset,
136
+ "start_date": start_date,
137
+ "end_date": end_date,
138
+ "metrics": metrics,
139
+ "equity_curve": equity_curve,
140
+ "hodl_curve": hodl_curve,
141
+ "decisions": decisions_log,
142
+ }
config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # OpenRouter
4
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
5
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
+
7
+ # Free models on OpenRouter
8
+ FREE_MODELS = [
9
+ "deepseek/deepseek-r1:free",
10
+ "meta-llama/llama-3.3-70b-instruct:free",
11
+ "qwen/qwen3-coder:free",
12
+ ]
13
+ DEFAULT_MODEL = FREE_MODELS[1]
14
+
15
+ # Supported assets
16
+ ASSETS = ["BTC/USDT", "ETH/USDT"]
17
+ ASSET_YFINANCE_MAP = {
18
+ "BTC/USDT": "BTC-USD",
19
+ "ETH/USDT": "ETH-USD",
20
+ }
21
+
22
+ # Benchmarks
23
+ BENCHMARKS = ["A", "B", "C"]
24
+
25
+ # Portfolio
26
+ INITIAL_CAPITAL = 10_000.0
27
+ TRADE_FEE = 0.001 # 0.1%
28
+
29
+ # Rate limiting (OpenRouter free tier)
30
+ MAX_REQUESTS_PER_MINUTE = 18 # conservative under 20
31
+ LLM_TIMEOUT = 120
32
+ LLM_MAX_RETRIES = 3
33
+
34
+ # DB
35
+ DB_PATH = os.getenv("DB_PATH", "/app/data/benchmark.db")
data/__init__.py ADDED
File without changes
data/indicators.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+
4
+
5
+ def compute_indicators(df: pd.DataFrame) -> pd.DataFrame:
6
+ """Add RSI, MA, MACD, Bollinger Bands to a OHLCV DataFrame."""
7
+ df = df.copy()
8
+ close = df["close"].astype(float)
9
+
10
+ # Moving averages
11
+ df["ma20"] = close.rolling(20).mean().round(2)
12
+ df["ma50"] = close.rolling(50).mean().round(2)
13
+
14
+ # RSI(14)
15
+ df["rsi"] = _rsi(close, 14).round(2)
16
+
17
+ # MACD (12,26,9)
18
+ ema12 = close.ewm(span=12, adjust=False).mean()
19
+ ema26 = close.ewm(span=26, adjust=False).mean()
20
+ df["macd"] = (ema12 - ema26).round(2)
21
+ df["macd_signal"] = df["macd"].ewm(span=9, adjust=False).mean().round(2)
22
+ df["macd_hist"] = (df["macd"] - df["macd_signal"]).round(2)
23
+
24
+ # Bollinger Bands (20, 2)
25
+ std20 = close.rolling(20).std()
26
+ df["bb_upper"] = (df["ma20"] + 2 * std20).round(2)
27
+ df["bb_lower"] = (df["ma20"] - 2 * std20).round(2)
28
+
29
+ return df
30
+
31
+
32
+ def _rsi(series: pd.Series, period: int = 14) -> pd.Series:
33
+ delta = series.diff()
34
+ gain = delta.clip(lower=0)
35
+ loss = -delta.clip(upper=0)
36
+ avg_gain = gain.ewm(com=period - 1, min_periods=period).mean()
37
+ avg_loss = loss.ewm(com=period - 1, min_periods=period).mean()
38
+ rs = avg_gain / avg_loss.replace(0, np.nan)
39
+ return 100 - (100 / (1 + rs))
40
+
41
+
42
+ def get_latest_indicators(df: pd.DataFrame) -> dict:
43
+ """Return the most recent indicator values as a dict."""
44
+ if df.empty:
45
+ return {}
46
+ row = df.iloc[-1]
47
+
48
+ def safe(val):
49
+ if pd.isna(val):
50
+ return None
51
+ return float(val)
52
+
53
+ return {
54
+ "rsi": safe(row.get("rsi")),
55
+ "ma20": safe(row.get("ma20")),
56
+ "ma50": safe(row.get("ma50")),
57
+ "macd": safe(row.get("macd")),
58
+ "macd_signal": safe(row.get("macd_signal")),
59
+ "macd_hist": safe(row.get("macd_hist")),
60
+ "bb_upper": safe(row.get("bb_upper")),
61
+ "bb_lower": safe(row.get("bb_lower")),
62
+ }
data/news.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import requests
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ CRYPTOPANIC_API_KEY = os.getenv("CRYPTOPANIC_API_KEY", "")
8
+ CRYPTOCOMPARE_API_KEY = os.getenv("CRYPTOCOMPARE_API_KEY", "")
9
+
10
+
11
+ def fetch_news(asset: str, date: str = None, limit: int = 10) -> list:
12
+ """Fetch crypto news. Returns list of {title, url, published_at}."""
13
+ currency = asset.split("/")[0] # BTC, ETH
14
+
15
+ # Try CryptoPanic first
16
+ if CRYPTOPANIC_API_KEY:
17
+ try:
18
+ return _fetch_cryptopanic(currency, limit)
19
+ except Exception as e:
20
+ logger.warning(f"CryptoPanic failed: {e}")
21
+
22
+ # Try CryptoCompare
23
+ try:
24
+ return _fetch_cryptocompare(currency, limit)
25
+ except Exception as e:
26
+ logger.warning(f"CryptoCompare failed: {e}")
27
+
28
+ return []
29
+
30
+
31
+ def _fetch_cryptopanic(currency: str, limit: int) -> list:
32
+ url = "https://cryptopanic.com/api/v1/posts/"
33
+ params = {
34
+ "auth_token": CRYPTOPANIC_API_KEY,
35
+ "currencies": currency,
36
+ "kind": "news",
37
+ "limit": limit,
38
+ }
39
+ resp = requests.get(url, params=params, timeout=15)
40
+ resp.raise_for_status()
41
+ data = resp.json()
42
+ results = []
43
+ for item in data.get("results", [])[:limit]:
44
+ results.append({
45
+ "title": item.get("title", ""),
46
+ "url": item.get("url", ""),
47
+ "published_at": item.get("published_at", ""),
48
+ })
49
+ return results
50
+
51
+
52
+ def _fetch_cryptocompare(currency: str, limit: int) -> list:
53
+ url = "https://min-api.cryptocompare.com/data/v2/news/"
54
+ params = {"categories": currency, "lang": "EN", "limit": limit}
55
+ if CRYPTOCOMPARE_API_KEY:
56
+ params["api_key"] = CRYPTOCOMPARE_API_KEY
57
+ resp = requests.get(url, params=params, timeout=15)
58
+ resp.raise_for_status()
59
+ data = resp.json()
60
+ results = []
61
+ for item in data.get("Data", [])[:limit]:
62
+ results.append({
63
+ "title": item.get("title", ""),
64
+ "url": item.get("url", ""),
65
+ "published_at": str(item.get("published_on", "")),
66
+ })
67
+ return results
data/onchain.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import requests
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ def fetch_fear_greed() -> dict:
8
+ """Fetch Fear & Greed index from alternative.me (no API key needed)."""
9
+ try:
10
+ resp = requests.get("https://api.alternative.me/fng/?limit=1", timeout=10)
11
+ resp.raise_for_status()
12
+ data = resp.json()
13
+ item = data["data"][0]
14
+ return {
15
+ "value": int(item["value"]),
16
+ "label": item["value_classification"],
17
+ "timestamp": item["timestamp"],
18
+ }
19
+ except Exception as e:
20
+ logger.warning(f"Fear & Greed fetch failed: {e}")
21
+ return {"value": 50, "label": "Neutral", "timestamp": ""}
22
+
23
+
24
+ def fetch_funding_rate(asset: str) -> float | None:
25
+ """Fetch current funding rate from Binance public API."""
26
+ symbol_map = {"BTC/USDT": "BTCUSDT", "ETH/USDT": "ETHUSDT"}
27
+ symbol = symbol_map.get(asset, asset.replace("/", ""))
28
+ try:
29
+ url = "https://fapi.binance.com/fapi/v1/fundingRate"
30
+ params = {"symbol": symbol, "limit": 1}
31
+ resp = requests.get(url, params=params, timeout=10)
32
+ resp.raise_for_status()
33
+ data = resp.json()
34
+ if data:
35
+ return float(data[-1]["fundingRate"]) * 100 # as percentage
36
+ except Exception as e:
37
+ logger.warning(f"Funding rate fetch failed for {asset}: {e}")
38
+ return None
39
+
40
+
41
+ def fetch_onchain_data(asset: str) -> dict:
42
+ """Aggregate all on-chain data for one asset."""
43
+ return {
44
+ "fear_greed": fetch_fear_greed(),
45
+ "funding_rate": fetch_funding_rate(asset),
46
+ }
data/prices.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime, timedelta
3
+ import pandas as pd
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def fetch_ohlcv(asset: str, start_date: str, end_date: str) -> pd.DataFrame:
9
+ """Fetch OHLCV data. Tries ccxt first, falls back to yfinance."""
10
+ try:
11
+ return _fetch_ccxt(asset, start_date, end_date)
12
+ except Exception as e:
13
+ logger.warning(f"ccxt failed for {asset}: {e}, falling back to yfinance")
14
+ return _fetch_yfinance(asset, start_date, end_date)
15
+
16
+
17
+ def _fetch_ccxt(asset: str, start_date: str, end_date: str) -> pd.DataFrame:
18
+ import ccxt
19
+ exchange = ccxt.binance({"enableRateLimit": True})
20
+ since = int(datetime.strptime(start_date, "%Y-%m-%d").timestamp() * 1000)
21
+ limit = 1000
22
+
23
+ all_candles = []
24
+ current_since = since
25
+ end_ts = int(datetime.strptime(end_date, "%Y-%m-%d").timestamp() * 1000)
26
+
27
+ while current_since < end_ts:
28
+ candles = exchange.fetch_ohlcv(asset, "1d", since=current_since, limit=limit)
29
+ if not candles:
30
+ break
31
+ all_candles.extend(candles)
32
+ current_since = candles[-1][0] + 86400000
33
+ if len(candles) < limit:
34
+ break
35
+
36
+ if not all_candles:
37
+ raise ValueError(f"No data returned from ccxt for {asset}")
38
+
39
+ df = pd.DataFrame(all_candles, columns=["timestamp", "open", "high", "low", "close", "volume"])
40
+ df["date"] = pd.to_datetime(df["timestamp"], unit="ms").dt.date.astype(str)
41
+ df = df[(df["date"] >= start_date) & (df["date"] <= end_date)]
42
+ df = df.drop_duplicates("date").sort_values("date").reset_index(drop=True)
43
+ return df
44
+
45
+
46
+ def _fetch_yfinance(asset: str, start_date: str, end_date: str) -> pd.DataFrame:
47
+ import yfinance as yf
48
+ from config import ASSET_YFINANCE_MAP
49
+
50
+ ticker = ASSET_YFINANCE_MAP.get(asset, asset.replace("/", "-"))
51
+ # Add one day buffer because yfinance end is exclusive
52
+ end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
53
+ data = yf.download(ticker, start=start_date, end=end_dt, progress=False, auto_adjust=True)
54
+
55
+ if data.empty:
56
+ raise ValueError(f"No data returned from yfinance for {ticker}")
57
+
58
+ df = pd.DataFrame()
59
+ df["open"] = data["Open"].values.flatten()
60
+ df["high"] = data["High"].values.flatten()
61
+ df["low"] = data["Low"].values.flatten()
62
+ df["close"] = data["Close"].values.flatten()
63
+ df["volume"] = data["Volume"].values.flatten()
64
+ df["date"] = data.index.strftime("%Y-%m-%d")
65
+ df = df.sort_values("date").reset_index(drop=True)
66
+ return df
67
+
68
+
69
+ def ohlcv_to_records(df: pd.DataFrame) -> list:
70
+ """Convert OHLCV DataFrame to list of dicts for prompts."""
71
+ records = []
72
+ for _, row in df.iterrows():
73
+ records.append({
74
+ "date": str(row["date"]),
75
+ "open": float(row["open"]),
76
+ "high": float(row["high"]),
77
+ "low": float(row["low"]),
78
+ "close": float(row["close"]),
79
+ "volume": float(row["volume"]),
80
+ })
81
+ return records
db/__init__.py ADDED
File without changes
db/store.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ import os
4
+ import uuid
5
+ import logging
6
+ from datetime import datetime
7
+ from config import DB_PATH
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def _get_conn():
13
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
14
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
15
+ conn.row_factory = sqlite3.Row
16
+ return conn
17
+
18
+
19
+ def init_db():
20
+ """Create tables if they don't exist."""
21
+ conn = _get_conn()
22
+ c = conn.cursor()
23
+ c.executescript("""
24
+ CREATE TABLE IF NOT EXISTS runs (
25
+ id TEXT PRIMARY KEY,
26
+ benchmark TEXT NOT NULL,
27
+ model TEXT NOT NULL,
28
+ asset TEXT NOT NULL,
29
+ start_date TEXT,
30
+ end_date TEXT,
31
+ status TEXT DEFAULT 'pending',
32
+ created_at TEXT,
33
+ completed_at TEXT,
34
+ metrics TEXT,
35
+ equity_curve TEXT,
36
+ hodl_curve TEXT
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS decisions (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ run_id TEXT NOT NULL,
42
+ date TEXT,
43
+ price REAL,
44
+ action TEXT,
45
+ size REAL,
46
+ confidence REAL,
47
+ reason TEXT,
48
+ agent_outputs TEXT,
49
+ portfolio_value REAL,
50
+ FOREIGN KEY (run_id) REFERENCES runs(id)
51
+ );
52
+ """)
53
+ conn.commit()
54
+ conn.close()
55
+ logger.info(f"DB initialized at {DB_PATH}")
56
+
57
+
58
+ def create_run(benchmark: str, model: str, asset: str, start_date: str, end_date: str) -> str:
59
+ run_id = str(uuid.uuid4())
60
+ conn = _get_conn()
61
+ conn.execute(
62
+ "INSERT INTO runs (id, benchmark, model, asset, start_date, end_date, status, created_at) VALUES (?,?,?,?,?,?,?,?)",
63
+ (run_id, benchmark, model, asset, start_date, end_date, "running", datetime.utcnow().isoformat()),
64
+ )
65
+ conn.commit()
66
+ conn.close()
67
+ return run_id
68
+
69
+
70
+ def complete_run(run_id: str, result: dict):
71
+ metrics_json = json.dumps(result.get("metrics", {}))
72
+ equity_json = json.dumps(result.get("equity_curve", []))
73
+ hodl_json = json.dumps(result.get("hodl_curve", []))
74
+
75
+ conn = _get_conn()
76
+ conn.execute(
77
+ "UPDATE runs SET status=?, completed_at=?, metrics=?, equity_curve=?, hodl_curve=? WHERE id=?",
78
+ ("completed", datetime.utcnow().isoformat(), metrics_json, equity_json, hodl_json, run_id),
79
+ )
80
+
81
+ for d in result.get("decisions", []):
82
+ conn.execute(
83
+ "INSERT INTO decisions (run_id, date, price, action, size, confidence, reason, agent_outputs, portfolio_value) VALUES (?,?,?,?,?,?,?,?,?)",
84
+ (
85
+ run_id,
86
+ d.get("date"),
87
+ d.get("price"),
88
+ d.get("action"),
89
+ d.get("size"),
90
+ d.get("confidence"),
91
+ d.get("reason"),
92
+ json.dumps(d.get("agent_outputs", {})),
93
+ d.get("portfolio_value"),
94
+ ),
95
+ )
96
+
97
+ conn.commit()
98
+ conn.close()
99
+
100
+
101
+ def fail_run(run_id: str, error: str):
102
+ conn = _get_conn()
103
+ conn.execute(
104
+ "UPDATE runs SET status=?, completed_at=?, metrics=? WHERE id=?",
105
+ ("failed", datetime.utcnow().isoformat(), json.dumps({"error": error}), run_id),
106
+ )
107
+ conn.commit()
108
+ conn.close()
109
+
110
+
111
+ def get_run(run_id: str) -> dict | None:
112
+ conn = _get_conn()
113
+ row = conn.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
114
+ conn.close()
115
+ if not row:
116
+ return None
117
+ return _row_to_run(row)
118
+
119
+
120
+ def get_leaderboard() -> list:
121
+ conn = _get_conn()
122
+ rows = conn.execute(
123
+ "SELECT * FROM runs WHERE status='completed' ORDER BY created_at DESC"
124
+ ).fetchall()
125
+ conn.close()
126
+ return [_row_to_run(r) for r in rows]
127
+
128
+
129
+ def get_decisions(run_id: str) -> list:
130
+ conn = _get_conn()
131
+ rows = conn.execute(
132
+ "SELECT * FROM decisions WHERE run_id=? ORDER BY date ASC", (run_id,)
133
+ ).fetchall()
134
+ conn.close()
135
+ result = []
136
+ for row in rows:
137
+ d = dict(row)
138
+ if d.get("agent_outputs"):
139
+ try:
140
+ d["agent_outputs"] = json.loads(d["agent_outputs"])
141
+ except Exception:
142
+ pass
143
+ result.append(d)
144
+ return result
145
+
146
+
147
+ def _row_to_run(row) -> dict:
148
+ d = dict(row)
149
+ for field in ("metrics", "equity_curve", "hodl_curve"):
150
+ if d.get(field):
151
+ try:
152
+ d[field] = json.loads(d[field])
153
+ except Exception:
154
+ d[field] = {}
155
+ return d
live/__init__.py ADDED
File without changes
live/daily_job.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Daily live trading job — run once per day via cron.
3
+ Idempotent: skips a day already processed.
4
+ Usage: python live/daily_job.py
5
+ """
6
+ import logging
7
+ import sys
8
+ import os
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ from datetime import datetime, date
13
+ from data.prices import fetch_ohlcv
14
+ from data.indicators import compute_indicators, get_latest_indicators
15
+ from data.news import fetch_news
16
+ from data.onchain import fetch_onchain_data
17
+ from backtest.portfolio import Portfolio
18
+ from agents.pipeline import build_pipeline
19
+ from db.store import init_db, _get_conn
20
+ from config import FREE_MODELS, ASSETS, BENCHMARKS
21
+
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger(__name__)
24
+
25
+ TODAY = date.today().isoformat()
26
+
27
+
28
+ def _already_processed(benchmark: str, model: str, asset: str, day: str) -> bool:
29
+ conn = _get_conn()
30
+ row = conn.execute(
31
+ "SELECT id FROM decisions WHERE run_id IN (SELECT id FROM runs WHERE benchmark=? AND model=? AND asset=? AND status='live') AND date=?",
32
+ (benchmark, model, asset, day),
33
+ ).fetchone()
34
+ conn.close()
35
+ return row is not None
36
+
37
+
38
+ def _get_or_create_live_run(benchmark: str, model: str, asset: str) -> str:
39
+ conn = _get_conn()
40
+ row = conn.execute(
41
+ "SELECT id FROM runs WHERE benchmark=? AND model=? AND asset=? AND status='live'",
42
+ (benchmark, model, asset),
43
+ ).fetchone()
44
+ if row:
45
+ run_id = row["id"]
46
+ else:
47
+ import uuid
48
+ run_id = str(uuid.uuid4())
49
+ from datetime import datetime
50
+ conn.execute(
51
+ "INSERT INTO runs (id, benchmark, model, asset, status, created_at) VALUES (?,?,?,?,?,?)",
52
+ (run_id, benchmark, model, asset, "live", datetime.utcnow().isoformat()),
53
+ )
54
+ conn.commit()
55
+ conn.close()
56
+ return run_id
57
+
58
+
59
+ def run_daily():
60
+ init_db()
61
+ logger.info(f"Daily live job — {TODAY}")
62
+
63
+ # Look back 60 days for indicators
64
+ from datetime import datetime, timedelta
65
+ lookback_start = (datetime.strptime(TODAY, "%Y-%m-%d") - timedelta(days=60)).strftime("%Y-%m-%d")
66
+
67
+ for model in FREE_MODELS:
68
+ for benchmark in BENCHMARKS:
69
+ for asset in ASSETS:
70
+ logger.info(f"Processing {benchmark}/{model}/{asset}")
71
+
72
+ if _already_processed(benchmark, model, asset, TODAY):
73
+ logger.info(f"Already processed {benchmark}/{model}/{asset}/{TODAY}, skipping")
74
+ continue
75
+
76
+ try:
77
+ df_raw = fetch_ohlcv(asset, lookback_start, TODAY)
78
+ df = compute_indicators(df_raw)
79
+ if df.empty:
80
+ logger.warning(f"No data for {asset}")
81
+ continue
82
+
83
+ row = df.iloc[-1]
84
+ price = float(row["close"])
85
+ indicators = get_latest_indicators(df)
86
+
87
+ run_id = _get_or_create_live_run(benchmark, model, asset)
88
+
89
+ # Load portfolio state from DB
90
+ conn = _get_conn()
91
+ last_snap = conn.execute(
92
+ "SELECT portfolio_value FROM decisions WHERE run_id=? ORDER BY date DESC LIMIT 1",
93
+ (run_id,),
94
+ ).fetchone()
95
+ conn.close()
96
+
97
+ from config import INITIAL_CAPITAL
98
+ portfolio_value = last_snap["portfolio_value"] if last_snap else INITIAL_CAPITAL
99
+ # Simplified snapshot (no persistent position tracking for now)
100
+ portfolio_snapshot = {
101
+ "cash": portfolio_value,
102
+ "position": 0.0,
103
+ "total_value": portfolio_value,
104
+ "drawdown": 0.0,
105
+ }
106
+
107
+ from data.prices import ohlcv_to_records
108
+ market_data = {
109
+ "asset": asset,
110
+ "current_price": price,
111
+ "date": TODAY,
112
+ "recent_ohlcv": ohlcv_to_records(df)[-30:],
113
+ "indicators": indicators,
114
+ "portfolio": portfolio_snapshot,
115
+ }
116
+
117
+ if benchmark in ("B", "C"):
118
+ market_data["news"] = fetch_news(asset, limit=5)
119
+ if benchmark == "C":
120
+ market_data["onchain"] = fetch_onchain_data(asset)
121
+
122
+ pipeline = build_pipeline(benchmark, model)
123
+ result = pipeline.decide(market_data)
124
+ decision = result["decision"]
125
+ agent_outputs = result.get("agent_outputs", {})
126
+
127
+ import json
128
+ conn = _get_conn()
129
+ conn.execute(
130
+ "INSERT INTO decisions (run_id, date, price, action, size, confidence, reason, agent_outputs, portfolio_value) VALUES (?,?,?,?,?,?,?,?,?)",
131
+ (
132
+ run_id, TODAY, price,
133
+ decision.get("action"), decision.get("size"),
134
+ decision.get("confidence"), decision.get("reason"),
135
+ json.dumps(agent_outputs), portfolio_value,
136
+ ),
137
+ )
138
+ conn.commit()
139
+ conn.close()
140
+ logger.info(f"Decision saved: {benchmark}/{model}/{asset}/{TODAY} -> {decision.get('action')}")
141
+
142
+ except Exception as e:
143
+ logger.error(f"Error for {benchmark}/{model}/{asset}: {e}", exc_info=True)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ run_daily()
llm/__init__.py ADDED
File without changes
llm/openrouter.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ import requests
4
+ from threading import Lock
5
+ from collections import deque
6
+ from config import OPENROUTER_API_KEY, OPENROUTER_BASE_URL, MAX_REQUESTS_PER_MINUTE, LLM_TIMEOUT, LLM_MAX_RETRIES
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class RateLimiter:
12
+ """Token-bucket style rate limiter: max N requests per 60s window."""
13
+
14
+ def __init__(self, max_per_minute: int = MAX_REQUESTS_PER_MINUTE):
15
+ self.max_per_minute = max_per_minute
16
+ self.timestamps: deque = deque()
17
+ self.lock = Lock()
18
+
19
+ def acquire(self):
20
+ with self.lock:
21
+ now = time.time()
22
+ # Remove timestamps older than 60s
23
+ while self.timestamps and now - self.timestamps[0] > 60:
24
+ self.timestamps.popleft()
25
+
26
+ if len(self.timestamps) >= self.max_per_minute:
27
+ sleep_for = 60 - (now - self.timestamps[0]) + 0.1
28
+ logger.info(f"Rate limit reached, sleeping {sleep_for:.1f}s")
29
+ time.sleep(sleep_for)
30
+ now = time.time()
31
+ while self.timestamps and now - self.timestamps[0] > 60:
32
+ self.timestamps.popleft()
33
+
34
+ self.timestamps.append(time.time())
35
+
36
+
37
+ _rate_limiter = RateLimiter()
38
+
39
+
40
+ class OpenRouterClient:
41
+ def __init__(self, model: str, api_key: str = OPENROUTER_API_KEY):
42
+ self.model = model
43
+ self.api_key = api_key
44
+ self.base_url = OPENROUTER_BASE_URL
45
+
46
+ def call(self, system_prompt: str, user_prompt: str) -> str:
47
+ """Call OpenRouter with retry + exponential backoff. Returns raw text."""
48
+ headers = {
49
+ "Authorization": f"Bearer {self.api_key}",
50
+ "Content-Type": "application/json",
51
+ "HTTP-Referer": "https://cryptoagentbench.github.io",
52
+ "X-Title": "CryptoAgentBench",
53
+ }
54
+ payload = {
55
+ "model": self.model,
56
+ "messages": [
57
+ {"role": "system", "content": system_prompt},
58
+ {"role": "user", "content": user_prompt},
59
+ ],
60
+ "temperature": 0.1,
61
+ "max_tokens": 512,
62
+ }
63
+
64
+ for attempt in range(LLM_MAX_RETRIES):
65
+ _rate_limiter.acquire()
66
+ try:
67
+ resp = requests.post(
68
+ f"{self.base_url}/chat/completions",
69
+ headers=headers,
70
+ json=payload,
71
+ timeout=LLM_TIMEOUT,
72
+ )
73
+ resp.raise_for_status()
74
+ data = resp.json()
75
+ content = data["choices"][0]["message"]["content"]
76
+ return content
77
+ except requests.exceptions.Timeout:
78
+ wait = 2 ** attempt
79
+ logger.warning(f"Timeout on attempt {attempt+1}, retrying in {wait}s")
80
+ time.sleep(wait)
81
+ except requests.exceptions.HTTPError as e:
82
+ status = e.response.status_code if e.response else None
83
+ if status in (429, 503, 502):
84
+ wait = 2 ** (attempt + 1)
85
+ logger.warning(f"HTTP {status} on attempt {attempt+1}, retrying in {wait}s")
86
+ time.sleep(wait)
87
+ else:
88
+ logger.error(f"HTTP error {status}: {e}")
89
+ break
90
+ except Exception as e:
91
+ logger.error(f"LLM call failed: {e}")
92
+ break
93
+
94
+ logger.error(f"All retries failed for model {self.model}, returning HOLD")
95
+ return '{"action": "HOLD", "size": 0.0, "confidence": 0.0, "reason": "LLM unavailable"}'
96
+
97
+
98
+ def ping_model(model: str, api_key: str = OPENROUTER_API_KEY) -> bool:
99
+ """Quick check if a model is responding."""
100
+ client = OpenRouterClient(model=model, api_key=api_key)
101
+ try:
102
+ result = client.call("You are a test.", "Reply with OK")
103
+ return bool(result)
104
+ except Exception:
105
+ return False
llm/prompts.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TRADER_SYSTEM = """You are a professional crypto trading agent. Your job is to analyze market data and make a single trading decision.
2
+
3
+ Rules:
4
+ - You MUST respond with ONLY a JSON object, no markdown, no explanation outside the JSON.
5
+ - action must be exactly one of: BUY, SELL, HOLD
6
+ - size is the fraction of available capital to use (0.0 to 1.0)
7
+ - confidence is your confidence level (0.0 to 1.0)
8
+ - reason is a brief explanation (max 100 words)
9
+
10
+ Response format (strict JSON):
11
+ {"action": "BUY|SELL|HOLD", "size": 0.5, "confidence": 0.7, "reason": "..."}"""
12
+
13
+ TECHNICAL_ANALYST_SYSTEM = """You are a technical analysis expert for crypto markets. Analyze the provided OHLCV data and technical indicators.
14
+
15
+ Provide a structured technical analysis in JSON format:
16
+ {"signal": "BULLISH|BEARISH|NEUTRAL", "strength": 0.0, "key_levels": {"support": 0.0, "resistance": 0.0}, "summary": "..."}
17
+
18
+ Respond ONLY with the JSON object."""
19
+
20
+ NEWS_ANALYST_SYSTEM = """You are a crypto news sentiment analyst. Analyze the provided news headlines and assess market sentiment.
21
+
22
+ Respond ONLY with a JSON object:
23
+ {"sentiment": "POSITIVE|NEGATIVE|NEUTRAL", "score": 0.0, "key_themes": ["theme1"], "summary": "..."}
24
+
25
+ score ranges from -1.0 (very negative) to 1.0 (very positive)."""
26
+
27
+ SENTIMENT_ANALYST_SYSTEM = """You are a crypto market sentiment analyst specializing in on-chain data and market psychology.
28
+
29
+ Analyze the provided Fear & Greed index, funding rates, and other sentiment indicators.
30
+
31
+ Respond ONLY with a JSON object:
32
+ {"sentiment": "EXTREME_FEAR|FEAR|NEUTRAL|GREED|EXTREME_GREED", "score": 0.0, "funding_bias": "LONG|SHORT|NEUTRAL", "summary": "..."}"""
33
+
34
+ RESEARCHER_SYSTEM = """You are a senior crypto research analyst moderating a bull vs bear debate. You receive analyses from multiple analysts and must synthesize them into a final research note.
35
+
36
+ Consider both bullish and bearish arguments objectively. Identify the strongest signals.
37
+
38
+ Respond ONLY with a JSON object:
39
+ {"verdict": "BULLISH|BEARISH|NEUTRAL", "conviction": 0.0, "bull_points": ["..."], "bear_points": ["..."], "synthesis": "..."}
40
+
41
+ conviction ranges from 0.0 to 1.0."""
42
+
43
+ RISK_MANAGER_SYSTEM = """You are a crypto portfolio risk manager. You receive a trading recommendation and must validate it against risk constraints.
44
+
45
+ Risk rules:
46
+ - Max position size: 80% of capital
47
+ - If drawdown > 20%, reduce position sizes by 50%
48
+ - Do not override HOLD decisions with BUY unless conviction > 0.6
49
+
50
+ Respond ONLY with a JSON object:
51
+ {"approved": true, "adjusted_action": "BUY|SELL|HOLD", "adjusted_size": 0.5, "risk_note": "..."}"""
52
+
53
+
54
+ def build_trader_prompt_A(market_data: dict) -> str:
55
+ """Benchmark A: single agent sees price + indicators directly."""
56
+ asset = market_data.get("asset", "BTC/USDT")
57
+ price = market_data.get("current_price", 0)
58
+ ohlcv = market_data.get("recent_ohlcv", [])
59
+ indicators = market_data.get("indicators", {})
60
+ portfolio = market_data.get("portfolio", {})
61
+
62
+ ohlcv_text = ""
63
+ if ohlcv:
64
+ ohlcv_text = "\nRecent OHLCV (last 7 days):\n"
65
+ for row in ohlcv[-7:]:
66
+ ohlcv_text += f" {row['date']}: O={row['open']:.2f} H={row['high']:.2f} L={row['low']:.2f} C={row['close']:.2f} V={row['volume']:.0f}\n"
67
+
68
+ ind_text = ""
69
+ if indicators:
70
+ ind_text = f"""
71
+ Technical Indicators:
72
+ RSI(14): {indicators.get('rsi', 'N/A')}
73
+ MA(20): {indicators.get('ma20', 'N/A')}
74
+ MA(50): {indicators.get('ma50', 'N/A')}
75
+ MACD: {indicators.get('macd', 'N/A')}
76
+ MACD Signal: {indicators.get('macd_signal', 'N/A')}
77
+ MACD Hist: {indicators.get('macd_hist', 'N/A')}
78
+ Bollinger Upper: {indicators.get('bb_upper', 'N/A')}
79
+ Bollinger Lower: {indicators.get('bb_lower', 'N/A')}"""
80
+
81
+ port_text = f"""
82
+ Portfolio Status:
83
+ Cash: ${portfolio.get('cash', 0):.2f}
84
+ Position: {portfolio.get('position', 0):.6f} {asset.split('/')[0]}
85
+ Total Value: ${portfolio.get('total_value', 0):.2f}"""
86
+
87
+ return f"""Asset: {asset}
88
+ Current Price: ${price:.2f}
89
+ {ohlcv_text}{ind_text}{port_text}
90
+
91
+ Based on this data, make your trading decision."""
92
+
93
+
94
+ def build_technical_analyst_prompt(market_data: dict) -> str:
95
+ asset = market_data.get("asset", "BTC/USDT")
96
+ price = market_data.get("current_price", 0)
97
+ ohlcv = market_data.get("recent_ohlcv", [])
98
+ indicators = market_data.get("indicators", {})
99
+
100
+ ohlcv_text = ""
101
+ if ohlcv:
102
+ ohlcv_text = "\nRecent OHLCV (last 14 days):\n"
103
+ for row in ohlcv[-14:]:
104
+ ohlcv_text += f" {row['date']}: O={row['open']:.2f} H={row['high']:.2f} L={row['low']:.2f} C={row['close']:.2f} V={row['volume']:.0f}\n"
105
+
106
+ ind_text = f"""
107
+ Indicators:
108
+ RSI(14): {indicators.get('rsi', 'N/A')}
109
+ MA(20): {indicators.get('ma20', 'N/A')} | MA(50): {indicators.get('ma50', 'N/A')}
110
+ MACD: {indicators.get('macd', 'N/A')} | Signal: {indicators.get('macd_signal', 'N/A')}
111
+ BB Upper: {indicators.get('bb_upper', 'N/A')} | BB Lower: {indicators.get('bb_lower', 'N/A')}"""
112
+
113
+ return f"Asset: {asset}\nCurrent Price: ${price:.2f}\n{ohlcv_text}{ind_text}\n\nProvide your technical analysis."
114
+
115
+
116
+ def build_news_analyst_prompt(news_items: list, asset: str) -> str:
117
+ if not news_items:
118
+ return f"No news available for {asset}. Respond with neutral sentiment."
119
+ headlines = "\n".join(f"- {item.get('title', '')}" for item in news_items[:10])
120
+ return f"Asset: {asset}\n\nRecent news headlines:\n{headlines}\n\nAnalyze the sentiment."
121
+
122
+
123
+ def build_sentiment_analyst_prompt(onchain_data: dict, asset: str) -> str:
124
+ fng = onchain_data.get("fear_greed", {})
125
+ funding = onchain_data.get("funding_rate", None)
126
+ return f"""Asset: {asset}
127
+
128
+ Fear & Greed Index: {fng.get('value', 'N/A')} ({fng.get('label', 'N/A')})
129
+ Funding Rate: {f'{funding:.4f}%' if funding is not None else 'N/A'}
130
+
131
+ Analyze the market sentiment."""
132
+
133
+
134
+ def build_researcher_prompt(tech_analysis: dict, news_analysis: dict, sentiment_analysis: dict, asset: str) -> str:
135
+ return f"""Asset: {asset}
136
+
137
+ Technical Analysis:
138
+ {tech_analysis}
139
+
140
+ News Analysis:
141
+ {news_analysis}
142
+
143
+ Sentiment Analysis:
144
+ {sentiment_analysis}
145
+
146
+ Synthesize these analyses into a final research verdict."""
147
+
148
+
149
+ def build_risk_manager_prompt(recommendation: dict, portfolio: dict) -> str:
150
+ return f"""Trading Recommendation:
151
+ {recommendation}
152
+
153
+ Portfolio Status:
154
+ Cash: ${portfolio.get('cash', 0):.2f}
155
+ Position: {portfolio.get('position', 0):.6f}
156
+ Total Value: ${portfolio.get('total_value', 0):.2f}
157
+ Current Drawdown: {portfolio.get('drawdown', 0):.1%}
158
+
159
+ Validate this recommendation against risk constraints."""
160
+
161
+
162
+ def build_trader_prompt_B(tech_analysis: dict, news_analysis: dict, portfolio: dict, asset: str, price: float) -> str:
163
+ return f"""Asset: {asset}
164
+ Current Price: ${price:.2f}
165
+
166
+ Technical Analysis Summary: {tech_analysis.get('summary', 'N/A')} (Signal: {tech_analysis.get('signal', 'N/A')})
167
+ News Sentiment Summary: {news_analysis.get('summary', 'N/A')} (Sentiment: {news_analysis.get('sentiment', 'N/A')}, Score: {news_analysis.get('score', 0):.2f})
168
+
169
+ Portfolio:
170
+ Cash: ${portfolio.get('cash', 0):.2f}
171
+ Total Value: ${portfolio.get('total_value', 0):.2f}
172
+
173
+ Make your trading decision based on the analyses above."""
174
+
175
+
176
+ def build_trader_prompt_C(research: dict, risk_decision: dict, portfolio: dict, asset: str, price: float) -> str:
177
+ return f"""Asset: {asset}
178
+ Current Price: ${price:.2f}
179
+
180
+ Research Verdict: {research.get('verdict', 'N/A')} (Conviction: {research.get('conviction', 0):.2f})
181
+ Research Synthesis: {research.get('synthesis', 'N/A')}
182
+
183
+ Risk Manager Assessment: {risk_decision.get('risk_note', 'N/A')}
184
+ Risk-Adjusted Action: {risk_decision.get('adjusted_action', 'HOLD')} (size: {risk_decision.get('adjusted_size', 0)})
185
+
186
+ Portfolio:
187
+ Cash: ${portfolio.get('cash', 0):.2f}
188
+ Total Value: ${portfolio.get('total_value', 0):.2f}
189
+
190
+ Make your final trading decision."""
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.5
2
+ uvicorn[standard]==0.32.1
3
+ pydantic==2.9.2
4
+ requests==2.32.3
5
+ pandas==2.2.3
6
+ numpy==2.1.3
7
+ yfinance==0.2.50
8
+ ccxt==4.4.32
9
+ python-dotenv==1.0.1