Spaces:
Running
Running
Mehdi commited on
Commit ·
68025ee
0
Parent(s):
feat: backend
Browse files- Dockerfile +18 -0
- README.md +30 -0
- agents/__init__.py +0 -0
- agents/analysts.py +72 -0
- agents/base.py +49 -0
- agents/pipeline.py +130 -0
- agents/researcher.py +28 -0
- agents/risk_manager.py +25 -0
- agents/trader.py +51 -0
- app.py +190 -0
- backtest/__init__.py +0 -0
- backtest/portfolio.py +154 -0
- backtest/runner.py +142 -0
- config.py +35 -0
- data/__init__.py +0 -0
- data/indicators.py +62 -0
- data/news.py +67 -0
- data/onchain.py +46 -0
- data/prices.py +81 -0
- db/__init__.py +0 -0
- db/store.py +155 -0
- live/__init__.py +0 -0
- live/daily_job.py +147 -0
- llm/__init__.py +0 -0
- llm/openrouter.py +105 -0
- llm/prompts.py +190 -0
- requirements.txt +9 -0
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
|