Spaces:
Running
Running
Sync from GitHub
Browse files- app/commentary.py +175 -0
- app/main.py +69 -0
- app/settings.py +4 -0
app/commentary.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Commentary Generator using OpenRouter API.
|
| 3 |
+
Generates human-readable market analysis from FinBERT + XGBoost results.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from .settings import get_settings
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def generate_commentary(
|
| 17 |
+
current_price: float,
|
| 18 |
+
predicted_price: float,
|
| 19 |
+
predicted_return: float,
|
| 20 |
+
sentiment_index: float,
|
| 21 |
+
sentiment_label: str,
|
| 22 |
+
top_influencers: list[dict],
|
| 23 |
+
news_count: int = 0,
|
| 24 |
+
) -> Optional[str]:
|
| 25 |
+
"""
|
| 26 |
+
Generate AI commentary using OpenRouter API.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
current_price: Current copper price
|
| 30 |
+
predicted_price: Model's predicted price for tomorrow
|
| 31 |
+
predicted_return: Expected return percentage
|
| 32 |
+
sentiment_index: Current sentiment score (-1 to 1)
|
| 33 |
+
sentiment_label: Bullish/Bearish/Neutral
|
| 34 |
+
top_influencers: List of top feature influencers
|
| 35 |
+
news_count: Number of news articles analyzed
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
AI-generated commentary or None if failed
|
| 39 |
+
"""
|
| 40 |
+
settings = get_settings()
|
| 41 |
+
|
| 42 |
+
if not settings.openrouter_api_key:
|
| 43 |
+
logger.warning("OpenRouter API key not configured, skipping commentary")
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
# Build the prompt
|
| 47 |
+
influencers_text = "\n".join([
|
| 48 |
+
f" - {inf.get('feature', 'Unknown')}: {inf.get('importance', 0)*100:.1f}%"
|
| 49 |
+
for inf in top_influencers[:5]
|
| 50 |
+
])
|
| 51 |
+
|
| 52 |
+
change_direction = "artış" if predicted_return > 0 else "düşüş"
|
| 53 |
+
change_emoji = "📈" if predicted_return > 0 else "📉"
|
| 54 |
+
|
| 55 |
+
prompt = f"""Sen bir finans analisti asistanısın. Aşağıdaki bakır (copper) piyasası verilerini analiz et ve yatırımcılar için kısa, anlaşılır bir Türkçe yorum yaz.
|
| 56 |
+
|
| 57 |
+
## Güncel Veriler:
|
| 58 |
+
- **Güncel Fiyat:** ${current_price:.4f}
|
| 59 |
+
- **Yarınki Tahmin:** ${predicted_price:.4f} ({change_emoji} %{abs(predicted_return*100):.2f} {change_direction})
|
| 60 |
+
- **Piyasa Duyarlılığı:** {sentiment_label} (Skor: {sentiment_index:.3f})
|
| 61 |
+
- **Analiz Edilen Haber Sayısı:** {news_count}
|
| 62 |
+
|
| 63 |
+
## En Etkili Faktörler (XGBoost Model):
|
| 64 |
+
{influencers_text}
|
| 65 |
+
|
| 66 |
+
## Talimatlar:
|
| 67 |
+
1. 3-4 paragraf yaz (toplam 150-200 kelime)
|
| 68 |
+
2. Teknik terimler kullanma, sade ve anlaşılır ol
|
| 69 |
+
3. İlk paragrafta genel durumu özetle
|
| 70 |
+
4. İkinci paragrafta önemli faktörleri açıkla
|
| 71 |
+
5. Son paragrafta kısa vadeli görünümü belirt
|
| 72 |
+
6. 🎯 emoji ile önemli noktaları vurgula
|
| 73 |
+
7. Bu finansal tavsiye DEĞİLDİR uyarısını ekle
|
| 74 |
+
|
| 75 |
+
Yorumunu yaz:"""
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 79 |
+
response = await client.post(
|
| 80 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 81 |
+
headers={
|
| 82 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 83 |
+
"Content-Type": "application/json",
|
| 84 |
+
"HTTP-Referer": "https://copper-mind.vercel.app",
|
| 85 |
+
"X-Title": "CopperMind AI Analysis",
|
| 86 |
+
},
|
| 87 |
+
json={
|
| 88 |
+
"model": settings.openrouter_model,
|
| 89 |
+
"messages": [
|
| 90 |
+
{
|
| 91 |
+
"role": "system",
|
| 92 |
+
"content": "Sen uzman bir emtia piyasası analistsin. Bakır fiyatları hakkında kısa ve öz Türkçe yorumlar yapıyorsun."
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"role": "user",
|
| 96 |
+
"content": prompt
|
| 97 |
+
}
|
| 98 |
+
],
|
| 99 |
+
"max_tokens": 500,
|
| 100 |
+
"temperature": 0.7,
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if response.status_code == 200:
|
| 105 |
+
data = response.json()
|
| 106 |
+
commentary = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 107 |
+
if commentary:
|
| 108 |
+
logger.info(f"AI commentary generated successfully ({len(commentary)} chars)")
|
| 109 |
+
return commentary.strip()
|
| 110 |
+
else:
|
| 111 |
+
logger.warning("Empty response from OpenRouter")
|
| 112 |
+
return None
|
| 113 |
+
else:
|
| 114 |
+
logger.error(f"OpenRouter API error: {response.status_code} - {response.text}")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Failed to generate AI commentary: {e}")
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# Cache for commentary (simple in-memory)
|
| 123 |
+
_commentary_cache: dict = {
|
| 124 |
+
"commentary": None,
|
| 125 |
+
"generated_at": None,
|
| 126 |
+
"expires_at": None,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def get_cached_commentary(
|
| 131 |
+
current_price: float,
|
| 132 |
+
predicted_price: float,
|
| 133 |
+
predicted_return: float,
|
| 134 |
+
sentiment_index: float,
|
| 135 |
+
sentiment_label: str,
|
| 136 |
+
top_influencers: list[dict],
|
| 137 |
+
news_count: int = 0,
|
| 138 |
+
ttl_minutes: int = 60,
|
| 139 |
+
) -> Optional[str]:
|
| 140 |
+
"""
|
| 141 |
+
Get cached commentary or generate new one if expired.
|
| 142 |
+
"""
|
| 143 |
+
global _commentary_cache
|
| 144 |
+
|
| 145 |
+
now = datetime.now()
|
| 146 |
+
|
| 147 |
+
# Check if cache is valid
|
| 148 |
+
if (
|
| 149 |
+
_commentary_cache["commentary"]
|
| 150 |
+
and _commentary_cache["expires_at"]
|
| 151 |
+
and now < _commentary_cache["expires_at"]
|
| 152 |
+
):
|
| 153 |
+
logger.debug("Returning cached AI commentary")
|
| 154 |
+
return _commentary_cache["commentary"]
|
| 155 |
+
|
| 156 |
+
# Generate new commentary
|
| 157 |
+
commentary = await generate_commentary(
|
| 158 |
+
current_price=current_price,
|
| 159 |
+
predicted_price=predicted_price,
|
| 160 |
+
predicted_return=predicted_return,
|
| 161 |
+
sentiment_index=sentiment_index,
|
| 162 |
+
sentiment_label=sentiment_label,
|
| 163 |
+
top_influencers=top_influencers,
|
| 164 |
+
news_count=news_count,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
if commentary:
|
| 168 |
+
from datetime import timedelta
|
| 169 |
+
_commentary_cache = {
|
| 170 |
+
"commentary": commentary,
|
| 171 |
+
"generated_at": now,
|
| 172 |
+
"expires_at": now + timedelta(minutes=ttl_minutes),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
return commentary
|
app/main.py
CHANGED
|
@@ -312,6 +312,75 @@ async def health_check():
|
|
| 312 |
)
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
# =============================================================================
|
| 316 |
# Root redirect (optional convenience)
|
| 317 |
# =============================================================================
|
|
|
|
| 312 |
)
|
| 313 |
|
| 314 |
|
| 315 |
+
# =============================================================================
|
| 316 |
+
# AI Commentary Endpoint
|
| 317 |
+
# =============================================================================
|
| 318 |
+
|
| 319 |
+
@app.get(
|
| 320 |
+
"/api/commentary",
|
| 321 |
+
summary="AI-generated market commentary",
|
| 322 |
+
description="Returns an AI-generated analysis of the current copper market situation using FinBERT + XGBoost results."
|
| 323 |
+
)
|
| 324 |
+
async def get_commentary(
|
| 325 |
+
symbol: str = Query(default="HG=F", description="Symbol to analyze")
|
| 326 |
+
):
|
| 327 |
+
"""
|
| 328 |
+
Generate AI commentary for the current market situation.
|
| 329 |
+
|
| 330 |
+
Uses OpenRouter API with a free LLM to create human-readable
|
| 331 |
+
analysis from FinBERT sentiment and XGBoost predictions.
|
| 332 |
+
"""
|
| 333 |
+
from app.commentary import get_cached_commentary
|
| 334 |
+
|
| 335 |
+
settings = get_settings()
|
| 336 |
+
|
| 337 |
+
# Check if OpenRouter is configured
|
| 338 |
+
if not settings.openrouter_api_key:
|
| 339 |
+
return {
|
| 340 |
+
"symbol": symbol,
|
| 341 |
+
"commentary": None,
|
| 342 |
+
"error": "AI commentary not configured. Set OPENROUTER_API_KEY environment variable.",
|
| 343 |
+
"generated_at": None,
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
# Get the latest analysis
|
| 347 |
+
try:
|
| 348 |
+
analysis = await get_analysis(symbol)
|
| 349 |
+
except HTTPException:
|
| 350 |
+
return {
|
| 351 |
+
"symbol": symbol,
|
| 352 |
+
"commentary": None,
|
| 353 |
+
"error": "No analysis data available to comment on.",
|
| 354 |
+
"generated_at": None,
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
# Get news count
|
| 358 |
+
with SessionLocal() as session:
|
| 359 |
+
week_ago = datetime.now() - timedelta(days=7)
|
| 360 |
+
news_count = session.query(func.count(NewsArticle.id)).filter(
|
| 361 |
+
NewsArticle.published >= week_ago
|
| 362 |
+
).scalar() or 0
|
| 363 |
+
|
| 364 |
+
# Generate commentary
|
| 365 |
+
commentary = await get_cached_commentary(
|
| 366 |
+
current_price=analysis.current_price,
|
| 367 |
+
predicted_price=analysis.predicted_price,
|
| 368 |
+
predicted_return=analysis.predicted_return,
|
| 369 |
+
sentiment_index=analysis.sentiment_index,
|
| 370 |
+
sentiment_label=analysis.sentiment_label,
|
| 371 |
+
top_influencers=[inf.dict() for inf in analysis.top_influencers],
|
| 372 |
+
news_count=news_count,
|
| 373 |
+
ttl_minutes=60, # Cache for 1 hour
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
return {
|
| 377 |
+
"symbol": symbol,
|
| 378 |
+
"commentary": commentary,
|
| 379 |
+
"error": None if commentary else "Failed to generate commentary",
|
| 380 |
+
"generated_at": datetime.now(timezone.utc).isoformat() if commentary else None,
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
# =============================================================================
|
| 385 |
# Root redirect (optional convenience)
|
| 386 |
# =============================================================================
|
app/settings.py
CHANGED
|
@@ -50,6 +50,10 @@ class Settings(BaseSettings):
|
|
| 50 |
tz: str = "Europe/Istanbul"
|
| 51 |
scheduler_enabled: bool = True
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
@property
|
| 54 |
def symbols_list(self) -> list[str]:
|
| 55 |
"""Parse comma-separated symbols into a list."""
|
|
|
|
| 50 |
tz: str = "Europe/Istanbul"
|
| 51 |
scheduler_enabled: bool = True
|
| 52 |
|
| 53 |
+
# OpenRouter AI Commentary
|
| 54 |
+
openrouter_api_key: Optional[str] = None
|
| 55 |
+
openrouter_model: str = "meta-llama/llama-3.2-3b-instruct:free"
|
| 56 |
+
|
| 57 |
@property
|
| 58 |
def symbols_list(self) -> list[str]:
|
| 59 |
"""Parse comma-separated symbols into a list."""
|