Initial commit: Gold Trading Simulator with AI-powered analysis

This commit is contained in:
Krikorios
2025-11-16 00:50:04 +02:00
commit 72c1d3adb7
128 changed files with 16232 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Services package
+270
View File
@@ -0,0 +1,270 @@
from typing import List, Dict, Optional
from datetime import datetime, timedelta
import uuid
from app.schemas.schemas import (
Alert,
AlertType,
AlertSeverity,
AlertsResponse,
PriceData,
NewsPriceCorrelation,
CorrelationAnalysisResponse,
)
from app.config import settings
class AlertService:
def __init__(self):
self.alerts: List[Alert] = []
self.price_history: List[PriceData] = []
self.last_price: Optional[float] = None
self.support_levels: List[float] = []
self.resistance_levels: List[float] = []
def set_support_resistance(self, support: List[float], resistance: List[float]):
"""Set support and resistance levels for breach detection"""
self.support_levels = support
self.resistance_levels = resistance
def add_price_data(self, price_data: PriceData):
"""Add new price data and check for alerts"""
self.price_history.append(price_data)
# Keep only last 1000 data points
if len(self.price_history) > 1000:
self.price_history = self.price_history[-1000:]
current_price = price_data.close
if self.last_price:
self._check_price_alerts(current_price, self.last_price)
self._check_volatility_alerts(price_data)
self._check_support_resistance_breach(current_price)
self.last_price = current_price
def _check_price_alerts(self, current_price: float, last_price: float):
"""Check for significant price movements"""
change_percent = ((current_price - last_price) / last_price) * 100
threshold = settings.PRICE_ALERT_THRESHOLD
if abs(change_percent) >= threshold:
if change_percent > 0:
alert_type = AlertType.PRICE_SPIKE
title = f"Gold Price Spike: +{change_percent:.2f}%"
severity = AlertSeverity.HIGH if change_percent > 2.0 else AlertSeverity.MEDIUM
else:
alert_type = AlertType.PRICE_DROP
title = f"Gold Price Drop: {change_percent:.2f}%"
severity = AlertSeverity.HIGH if change_percent < -2.0 else AlertSeverity.MEDIUM
alert = Alert(
id=str(uuid.uuid4()),
type=alert_type,
severity=severity,
title=title,
message=f"Gold price moved from ${last_price:.2f} to ${current_price:.2f} ({change_percent:+.2f}%)",
price=current_price,
change_percent=change_percent,
timestamp=datetime.now(),
action_required=severity == AlertSeverity.HIGH,
)
self.alerts.append(alert)
def _check_volatility_alerts(self, price_data: PriceData):
"""Check for high volatility conditions"""
if len(self.price_history) < 20:
return
# Calculate ATR-like volatility
recent_data = self.price_history[-20:]
ranges = [d.high - d.low for d in recent_data]
avg_range = sum(ranges) / len(ranges)
current_range = price_data.high - price_data.low
# Alert if current range is 2x average
if current_range > avg_range * 2:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.HIGH_VOLATILITY,
severity=AlertSeverity.MEDIUM,
title="High Volatility Detected",
message=f"Current price range ${current_range:.2f} is significantly higher than average ${avg_range:.2f}",
price=price_data.close,
timestamp=datetime.now(),
)
self.alerts.append(alert)
def _check_support_resistance_breach(self, current_price: float):
"""Check if price breached support or resistance levels"""
if not self.last_price:
return
# Check resistance breach (upward)
for resistance in self.resistance_levels:
if self.last_price < resistance <= current_price:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.RESISTANCE_BREACH,
severity=AlertSeverity.HIGH,
title=f"Resistance Breached: ${resistance:.2f}",
message=f"Gold price broke above resistance level of ${resistance:.2f}",
price=current_price,
timestamp=datetime.now(),
action_required=True,
)
self.alerts.append(alert)
# Check support breach (downward)
for support in self.support_levels:
if self.last_price > support >= current_price:
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.SUPPORT_BREACH,
severity=AlertSeverity.HIGH,
title=f"Support Breached: ${support:.2f}",
message=f"Gold price broke below support level of ${support:.2f}",
price=current_price,
timestamp=datetime.now(),
action_required=True,
)
self.alerts.append(alert)
def add_news_alert(self, news_title: str, impact: str, sentiment: str):
"""Add alert for breaking news"""
severity_map = {
"HIGH": AlertSeverity.CRITICAL,
"MEDIUM": AlertSeverity.HIGH,
"LOW": AlertSeverity.MEDIUM,
}
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.NEWS_BREAKING,
severity=severity_map.get(impact, AlertSeverity.MEDIUM),
title=f"Breaking: {news_title[:50]}...",
message=f"High-impact news detected: {news_title}",
timestamp=datetime.now(),
action_required=impact == "HIGH",
)
self.alerts.append(alert)
def add_economic_event_alert(self, event_title: str, importance: str):
"""Add alert for upcoming economic event"""
severity_map = {
"HIGH": AlertSeverity.HIGH,
"MEDIUM": AlertSeverity.MEDIUM,
"LOW": AlertSeverity.LOW,
}
alert = Alert(
id=str(uuid.uuid4()),
type=AlertType.ECONOMIC_EVENT,
severity=severity_map.get(importance, AlertSeverity.MEDIUM),
title=f"Upcoming: {event_title}",
message=f"Important economic event scheduled: {event_title}",
timestamp=datetime.now(),
action_required=importance == "HIGH",
)
self.alerts.append(alert)
def get_alerts(self, limit: int = 50) -> AlertsResponse:
"""Get recent alerts"""
# Sort by timestamp (newest first)
sorted_alerts = sorted(self.alerts, key=lambda x: x.timestamp, reverse=True)
# Limit results
recent_alerts = sorted_alerts[:limit]
# Count critical alerts
critical_count = sum(1 for a in recent_alerts if a.severity == AlertSeverity.CRITICAL)
# For MVP, all alerts are unread
unread_count = len(recent_alerts)
return AlertsResponse(
alerts=recent_alerts,
critical_count=critical_count,
unread_count=unread_count,
)
def clear_old_alerts(self, hours: int = 24):
"""Remove alerts older than specified hours"""
cutoff = datetime.now() - timedelta(hours=hours)
self.alerts = [a for a in self.alerts if a.timestamp > cutoff]
def analyze_news_price_correlation(
self,
news_articles: List,
price_data: List[PriceData],
) -> CorrelationAnalysisResponse:
"""Analyze correlation between news and price movements"""
correlations = []
for article in news_articles:
news_time = article.published_at
# Find price before and after news
price_before = None
price_after = None
for i, data in enumerate(price_data):
data_time = datetime.fromtimestamp(data.time)
# Price before news (within 1 hour before)
if data_time < news_time and (news_time - data_time).total_seconds() < 3600:
price_before = data.close
# Price after news (within 1 hour after)
if data_time > news_time and (data_time - news_time).total_seconds() < 3600:
if not price_after: # Take first price after
price_after = data.close
if price_before and price_after:
price_change = price_after - price_before
price_change_percent = (price_change / price_before) * 100
time_delta = 60 # Approximate minutes
# Determine correlation strength
if abs(price_change_percent) > 1.0:
strength = "STRONG"
elif abs(price_change_percent) > 0.5:
strength = "MODERATE"
else:
strength = "WEAK"
correlation = NewsPriceCorrelation(
news_id=article.id,
news_title=article.title,
news_time=news_time,
price_before=price_before,
price_after=price_after,
price_change=price_change,
price_change_percent=price_change_percent,
time_delta_minutes=time_delta,
correlation_strength=strength,
)
correlations.append(correlation)
# Calculate statistics
significant_events = sum(1 for c in correlations if c.correlation_strength in ["STRONG", "MODERATE"])
avg_impact = (
sum(abs(c.price_change_percent) for c in correlations) / len(correlations)
if correlations else 0.0
)
return CorrelationAnalysisResponse(
correlations=correlations[:20], # Limit to 20 most recent
significant_events=significant_events,
avg_price_impact=avg_impact,
)
# Global instance
alert_service = AlertService()
+133
View File
@@ -0,0 +1,133 @@
import httpx
from typing import List, Dict
from datetime import datetime
from app.config import settings
from app.schemas.schemas import PriceData
class AlphaVantageService:
def __init__(self):
self.base_url = settings.ALPHA_VANTAGE_BASE_URL
self.api_key = settings.ALPHA_VANTAGE_API_KEY
async def get_gold_daily_data(
self, output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch daily gold price data from Alpha Vantage using GLD ETF
GLD tracks gold prices closely (1 share ≈ 0.1 oz of gold)
Args:
output_size: 'compact' (100 data points) or 'full' (20+ years)
Returns:
List of PriceData objects
"""
params = {
"function": "TIME_SERIES_DAILY",
"symbol": "GLD",
"outputsize": output_size,
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
if "Time Series (Daily)" not in data:
raise ValueError(f"Invalid API response: {data}")
time_series = data["Time Series (Daily)"]
price_data = []
for date_str, values in time_series.items():
# Convert date to Unix timestamp
dt = datetime.strptime(date_str, "%Y-%m-%d")
timestamp = int(dt.timestamp())
price_data.append(
PriceData(
time=timestamp,
open=float(values["1. open"]),
high=float(values["2. high"]),
low=float(values["3. low"]),
close=float(values["4. close"]),
)
)
# Sort by time (oldest first)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_gold_intraday_data(
self, interval: str = "15min", output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch intraday gold price data using GLD ETF
Args:
interval: '1min', '5min', '15min', '30min', '60min'
output_size: 'compact' or 'full'
Returns:
List of PriceData objects
"""
params = {
"function": "TIME_SERIES_INTRADAY",
"symbol": "GLD",
"interval": interval,
"outputsize": output_size,
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
time_series_key = f"Time Series ({interval})"
if time_series_key not in data:
raise ValueError(f"Invalid API response: {data}")
time_series = data[time_series_key]
price_data = []
for datetime_str, values in time_series.items():
dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
timestamp = int(dt.timestamp())
price_data.append(
PriceData(
time=timestamp,
open=float(values["1. open"]),
high=float(values["2. high"]),
low=float(values["3. low"]),
close=float(values["4. close"]),
)
)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_current_gold_price(self) -> float:
"""Get current gold price using GLD ETF latest price"""
params = {
"function": "GLOBAL_QUOTE",
"symbol": "GLD",
"apikey": self.api_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
if "Global Quote" not in data:
raise ValueError(f"Invalid API response: {data}")
quote = data["Global Quote"]
return float(quote["05. price"])
alpha_vantage_service = AlphaVantageService()
@@ -0,0 +1,37 @@
from __future__ import annotations
import httpx
from typing import List, Literal, Dict, Any
BINANCE_REST = "https://api.binance.com/api/v3/klines"
Interval = Literal["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d"]
async def fetch_klines(symbol: str, interval: Interval, limit: int = 500) -> List[Dict[str, Any]]:
"""
Fetch OHLCV klines from Binance REST. Returns list of dicts with fields:
time, open, high, low, close, volume
"""
params = {"symbol": symbol.upper().replace("/", ""), "interval": interval, "limit": min(max(limit, 1), 1000)}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(BINANCE_REST, params=params)
r.raise_for_status()
data = r.json()
out: List[Dict[str, Any]] = []
for row in data:
# Binance format
# [ openTime, open, high, low, close, volume, closeTime, ... ]
out.append(
{
"time": int(row[0] // 1000),
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
)
# Ensure ascending by time
out.sort(key=lambda x: x["time"])
return out
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Any, Dict, List
from datetime import datetime, timezone
import threading
class DecisionStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self._items: List[Dict[str, Any]] = []
def add(self, item: Dict[str, Any]) -> None:
with self._lock:
self._items.append(item)
if len(self._items) > 1000:
# keep last 1000
self._items = self._items[-1000:]
def latest(self, limit: int = 50) -> List[Dict[str, Any]]:
with self._lock:
return list(reversed(self._items[-limit:]))
# singleton store
store = DecisionStore()
def log_decision(
*,
symbol: str,
timeframe: str,
style: str,
recommendation: str,
confidence: float,
risk_level: str,
rationale: str,
inputs_hash: str | None = None,
cost: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
item = {
"id": f"dec_{int(datetime.now(timezone.utc).timestamp()*1000)}",
"time": now,
"symbol": symbol,
"timeframe": timeframe,
"style": style,
"recommendation": recommendation,
"confidence": confidence,
"risk_level": risk_level,
"rationale": rationale,
"inputs_hash": inputs_hash,
"cost": cost or {},
}
store.add(item)
return item
+142
View File
@@ -0,0 +1,142 @@
import httpx
from typing import List, Dict
from datetime import datetime, timedelta
from app.schemas.schemas import PriceData
class GoldAPIService:
"""
Multi-source gold price service using free APIs:
- FXRatesAPI for historical XAU/USD data (no API key needed)
- GoldPrice.org for real-time spot prices
"""
def __init__(self):
self.fxrates_base_url = "https://api.fxratesapi.com"
self.goldprice_url = "https://data-asg.goldprice.org/dbXRates/USD"
async def get_gold_daily_data(
self, output_size: str = "compact"
) -> List[PriceData]:
"""
Fetch daily gold (XAU/USD) price data from FXRatesAPI
Args:
output_size: 'compact' (~100 days) or 'full' (~1 year)
Returns:
List of PriceData objects with actual XAU/USD prices
"""
# Calculate date range
end_date = datetime.now()
if output_size == "full":
start_date = end_date - timedelta(days=365)
else:
start_date = end_date - timedelta(days=100)
params = {
"start_date": start_date.strftime("%Y-%m-%d"),
"end_date": end_date.strftime("%Y-%m-%d"),
"base": "XAU",
"currencies": "USD",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.fxrates_base_url}/timeseries", params=params
)
response.raise_for_status()
data = response.json()
if not data.get("success") or "rates" not in data:
raise ValueError(f"Invalid API response: {data}")
rates = data["rates"]
price_data = []
for date_str, rate_data in rates.items():
# Parse the ISO timestamp
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
timestamp = int(dt.timestamp())
# FXRatesAPI gives us XAU price in USD (1 oz gold = X USD)
price = rate_data["USD"]
# Since we don't have OHLC from this API, we'll use the close price
# for all values (this is a limitation of free APIs)
price_data.append(
PriceData(
time=timestamp,
open=price,
high=price * 1.002, # Add small variance for visual effect
low=price * 0.998,
close=price,
)
)
# Sort by time (oldest first)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_gold_intraday_data(
self, interval: str = "15min", output_size: str = "compact"
) -> List[PriceData]:
"""
Fallback to daily data for intraday (free APIs don't provide intraday)
Or fetch current price and simulate recent data points
"""
# For free tier, we'll return simulated intraday data based on current price
current_price = await self.get_current_gold_price()
price_data = []
now = datetime.now()
# Generate last 24 hours of data points
intervals = {
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = intervals.get(interval, 15 * 60)
points = 100 if output_size == "compact" else 500
for i in range(points):
timestamp = int((now - timedelta(seconds=interval_seconds * i)).timestamp())
# Add small random variance (±0.5%)
variance = 1.0 + ((i % 10 - 5) * 0.001)
price = current_price * variance
price_data.append(
PriceData(
time=timestamp,
open=price,
high=price * 1.001,
low=price * 0.999,
close=price,
)
)
price_data.sort(key=lambda x: x.time)
return price_data
async def get_current_gold_price(self) -> float:
"""Get current spot gold price from FXRatesAPI (free, no API key)"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.fxrates_base_url}/latest",
params={"base": "XAU", "currencies": "USD"}
)
response.raise_for_status()
data = response.json()
if not data.get("success") or "rates" not in data:
raise ValueError(f"Invalid API response: {data}")
# Get current XAU/USD price
return float(data["rates"]["USD"])
gold_api_service = GoldAPIService()
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import List, Dict, Any
import httpx
from app.config import settings
ALPHA_BASE = "https://www.alphavantage.co/query"
async def fetch_fx_intraday(symbol: str = "XAUUSD", interval: str = "1min") -> List[Dict[str, Any]]:
from_symbol = symbol[:3].upper()
to_symbol = symbol[3:].upper()
params = {
"function": "FX_INTRADAY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"interval": interval,
"outputsize": "compact",
"apikey": settings.ALPHA_VANTAGE_API_KEY or "demo",
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(ALPHA_BASE, params=params)
r.raise_for_status()
js = r.json()
key = f"Time Series FX ({interval})"
series = js.get(key) or {}
out: List[Dict[str, Any]] = []
# Alpha returns in reverse chronological; convert to ascending
for ts, row in reversed(list(series.items())):
# ts like '2024-11-01 10:05:00'
# Convert to seconds
# We avoid datetime parsing heavy ops; split string
date_part, time_part = ts.split(" ")
y, m, d = map(int, date_part.split("-"))
hh, mm, ss = map(int, time_part.split(":"))
import calendar, datetime as dt
seconds = int(calendar.timegm(dt.datetime(y, m, d, hh, mm, ss).timetuple()))
out.append(
{
"time": seconds,
"open": float(row["1. open"]),
"high": float(row["2. high"]),
"low": float(row["3. low"]),
"close": float(row["4. close"]),
"volume": float(row.get("5. volume", 0.0)),
}
)
return out
async def fetch_fx_daily(symbol: str = "XAUUSD") -> List[Dict[str, Any]]:
from_symbol = symbol[:3].upper()
to_symbol = symbol[3:].upper()
params = {
"function": "FX_DAILY",
"from_symbol": from_symbol,
"to_symbol": to_symbol,
"outputsize": "compact",
"apikey": settings.ALPHA_VANTAGE_API_KEY or "demo",
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(ALPHA_BASE, params=params)
r.raise_for_status()
js = r.json()
key = "Time Series FX (Daily)"
series = js.get(key) or {}
out: List[Dict[str, Any]] = []
for ts, row in reversed(list(series.items())):
# ts like '2024-11-01'
import calendar, datetime as dt
y, m, d = map(int, ts.split("-"))
seconds = int(calendar.timegm(dt.datetime(y, m, d, 0, 0, 0).timetuple()))
out.append(
{
"time": seconds,
"open": float(row["1. open"]),
"high": float(row["2. high"]),
"low": float(row["3. low"]),
"close": float(row["4. close"]),
"volume": 0.0,
}
)
return out
+320
View File
@@ -0,0 +1,320 @@
import httpx
from typing import List, Dict
from datetime import datetime, timedelta
from textblob import TextBlob
import hashlib
from app.config import settings
from app.schemas.schemas import (
NewsArticle,
NewsFeedResponse,
Sentiment,
EconomicEvent,
EconomicCalendarResponse,
)
class NewsService:
def __init__(self):
self.alpha_vantage_key = settings.ALPHA_VANTAGE_API_KEY
self.finnhub_key = settings.FINNHUB_API_KEY
self.news_api_key = settings.NEWS_API_KEY
# Gold-related keywords for relevance scoring
self.gold_keywords = {
"high_relevance": [
"gold", "xau", "precious metals", "bullion", "gold price",
"gold market", "gold trading", "gold miners", "gold etf"
],
"medium_relevance": [
"federal reserve", "fed", "inflation", "interest rates",
"dollar", "usd", "monetary policy", "central bank",
"jerome powell", "treasury", "bonds"
],
"context_relevance": [
"geopolitics", "war", "sanctions", "recession",
"crisis", "safe haven", "risk off", "uncertainty"
]
}
# Impact categories
self.impact_categories = {
"MONETARY_POLICY": ["federal reserve", "fed", "interest rate", "monetary policy", "central bank"],
"GEOPOLITICS": ["war", "conflict", "sanctions", "tension", "geopolitical"],
"ECONOMIC_DATA": ["inflation", "cpi", "gdp", "employment", "jobs", "unemployment"],
"MARKET_SENTIMENT": ["risk", "sentiment", "volatility", "safe haven"],
"COMMODITY": ["gold", "precious metals", "bullion", "commodities"],
}
def _calculate_relevance_score(self, text: str) -> float:
"""Calculate how relevant a news article is to gold trading"""
text_lower = text.lower()
score = 0.0
# High relevance keywords
for keyword in self.gold_keywords["high_relevance"]:
if keyword in text_lower:
score += 0.4
# Medium relevance keywords
for keyword in self.gold_keywords["medium_relevance"]:
if keyword in text_lower:
score += 0.2
# Context relevance keywords
for keyword in self.gold_keywords["context_relevance"]:
if keyword in text_lower:
score += 0.1
return min(score, 1.0)
def _categorize_news(self, text: str) -> str:
"""Categorize news based on content"""
text_lower = text.lower()
for category, keywords in self.impact_categories.items():
for keyword in keywords:
if keyword in text_lower:
return category
return "OTHER"
def _analyze_sentiment(self, text: str) -> tuple[Sentiment, float]:
"""Analyze sentiment using TextBlob"""
try:
analysis = TextBlob(text)
polarity = analysis.sentiment.polarity
if polarity > 0.1:
sentiment = Sentiment.POSITIVE
elif polarity < -0.1:
sentiment = Sentiment.NEGATIVE
else:
sentiment = Sentiment.NEUTRAL
return sentiment, polarity
except Exception:
return Sentiment.NEUTRAL, 0.0
def _assess_gold_impact(self, sentiment: Sentiment, category: str, relevance: float) -> str:
"""Assess impact level on gold prices"""
# High impact categories
high_impact_cats = ["MONETARY_POLICY", "ECONOMIC_DATA"]
if relevance > 0.7:
if category in high_impact_cats:
return "HIGH"
return "MEDIUM"
elif relevance > 0.4:
return "MEDIUM"
else:
return "LOW"
async def fetch_alpha_vantage_news(self, topics: str = "economy_monetary,finance") -> List[NewsArticle]:
"""Fetch news from Alpha Vantage News Sentiment API"""
try:
params = {
"function": "NEWS_SENTIMENT",
"topics": topics,
"limit": 50,
"apikey": self.alpha_vantage_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
settings.ALPHA_VANTAGE_BASE_URL,
params=params
)
response.raise_for_status()
data = response.json()
if "feed" not in data:
return []
articles = []
for item in data["feed"]:
title = item.get("title", "")
summary = item.get("summary", "")
full_text = f"{title} {summary}"
relevance = self._calculate_relevance_score(full_text)
# Filter only gold-relevant news
if relevance < 0.3:
continue
sentiment, score = self._analyze_sentiment(full_text)
category = self._categorize_news(full_text)
impact = self._assess_gold_impact(sentiment, category, relevance)
# Parse published date
published_str = item.get("time_published", "")
try:
published_at = datetime.strptime(published_str, "%Y%m%dT%H%M%S")
except:
published_at = datetime.now()
article_id = hashlib.md5(f"{title}{published_str}".encode()).hexdigest()
articles.append(
NewsArticle(
id=article_id,
source=item.get("source", "Alpha Vantage"),
title=title,
description=summary,
url=item.get("url", ""),
published_at=published_at,
sentiment=sentiment,
sentiment_score=score,
impact_on_gold=impact,
relevance_score=relevance,
category=category,
)
)
return articles
except Exception as e:
print(f"Error fetching Alpha Vantage news: {e}")
return []
async def fetch_finnhub_news(self) -> List[NewsArticle]:
"""Fetch gold-related news from Finnhub"""
if not self.finnhub_key:
return []
try:
# Get general market news
params = {
"category": "forex",
"token": self.finnhub_key,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{settings.FINNHUB_BASE_URL}/news",
params=params
)
response.raise_for_status()
data = response.json()
articles = []
for item in data[:50]: # Limit to 50 articles
title = item.get("headline", "")
summary = item.get("summary", "")
full_text = f"{title} {summary}"
relevance = self._calculate_relevance_score(full_text)
# Filter only gold-relevant news
if relevance < 0.3:
continue
sentiment, score = self._analyze_sentiment(full_text)
category = self._categorize_news(full_text)
impact = self._assess_gold_impact(sentiment, category, relevance)
published_at = datetime.fromtimestamp(item.get("datetime", 0))
article_id = hashlib.md5(f"{title}{item.get('id', '')}".encode()).hexdigest()
articles.append(
NewsArticle(
id=article_id,
source=item.get("source", "Finnhub"),
title=title,
description=summary,
url=item.get("url", ""),
published_at=published_at,
sentiment=sentiment,
sentiment_score=score,
impact_on_gold=impact,
relevance_score=relevance,
category=category,
)
)
return articles
except Exception as e:
print(f"Error fetching Finnhub news: {e}")
return []
async def get_aggregated_news_feed(self) -> NewsFeedResponse:
"""Get aggregated news from all sources"""
# Fetch from multiple sources
alpha_news = await self.fetch_alpha_vantage_news()
finnhub_news = await self.fetch_finnhub_news() if self.finnhub_key else []
# Combine and deduplicate
all_articles = alpha_news + finnhub_news
# Remove duplicates based on similar titles
unique_articles = []
seen_titles = set()
for article in all_articles:
title_key = article.title.lower()[:50] # First 50 chars
if title_key not in seen_titles:
seen_titles.add(title_key)
unique_articles.append(article)
# Sort by published date (newest first)
unique_articles.sort(key=lambda x: x.published_at, reverse=True)
# Limit to most recent 50
unique_articles = unique_articles[:50]
# Calculate statistics
bullish_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.POSITIVE)
bearish_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.NEGATIVE)
neutral_count = sum(1 for a in unique_articles if a.sentiment == Sentiment.NEUTRAL)
avg_sentiment = (
sum(a.sentiment_score for a in unique_articles) / len(unique_articles)
if unique_articles else 0.0
)
# Determine overall sentiment
if avg_sentiment > 0.1:
overall_sentiment = Sentiment.POSITIVE
elif avg_sentiment < -0.1:
overall_sentiment = Sentiment.NEGATIVE
else:
overall_sentiment = Sentiment.NEUTRAL
return NewsFeedResponse(
articles=unique_articles,
total_count=len(unique_articles),
bullish_count=bullish_count,
bearish_count=bearish_count,
neutral_count=neutral_count,
overall_sentiment=overall_sentiment,
avg_sentiment_score=avg_sentiment,
)
async def get_economic_calendar(self) -> EconomicCalendarResponse:
"""Get upcoming economic events that impact gold"""
# This would integrate with economic calendar APIs
# For MVP, return curated list of upcoming events
# In production, integrate with:
# - Forex Factory API
# - Investing.com Economic Calendar
# - Alpha Vantage Economic Indicators
# For now, return empty with structure
events = []
# Count high-impact upcoming events
now = datetime.now()
upcoming_high_impact = sum(
1 for e in events
if e.importance == "HIGH" and e.event_date > now
)
return EconomicCalendarResponse(
events=events,
upcoming_high_impact=upcoming_high_impact,
)
news_service = NewsService()
+139
View File
@@ -0,0 +1,139 @@
import httpx
import json
from typing import List
from app.config import settings
from app.schemas.schemas import (
AIAnalysisRequest,
AIAnalysisResponse,
Recommendation,
RiskLevel,
SupportResistance,
)
class OpenRouterService:
def __init__(self):
self.base_url = settings.OPENROUTER_BASE_URL
self.api_key = settings.OPENROUTER_API_KEY
self.model = settings.OPENROUTER_MODEL
async def analyze_scenario(self, request: AIAnalysisRequest) -> AIAnalysisResponse:
"""
Analyze trading scenario using Claude 3.5 Sonnet via OpenRouter
Args:
request: AIAnalysisRequest with price data and indicators
Returns:
AIAnalysisResponse with recommendation and analysis
"""
# Prepare recent price data for analysis
recent_prices = request.price_data[-50:] if len(request.price_data) > 50 else request.price_data
# Format price data for the AI
price_summary = f"Current Price: ${request.current_price:.2f}\n"
price_summary += f"Recent Close Prices: {[f'${p.close:.2f}' for p in recent_prices[-10:]]}\n"
# Calculate basic statistics
prices = [p.close for p in recent_prices]
avg_price = sum(prices) / len(prices)
price_range = max(prices) - min(prices)
# Create analysis prompt
prompt = f"""You are a senior quantitative analyst specializing in gold (XAU/USD) trading. Analyze the following market data and provide a trading recommendation.
Market Data:
{price_summary}
Average Price (last 50 periods): ${avg_price:.2f}
Price Range: ${price_range:.2f}
Technical Indicators:
{json.dumps(request.indicators, indent=2)}
Based on this data, provide:
1. A clear recommendation: BUY, SELL, or HOLD
2. Confidence level (0-100%)
3. Detailed reasoning (2-3 sentences)
4. Support and resistance levels (up to 3 each)
5. Risk level assessment: LOW, MEDIUM, or HIGH
Respond in JSON format:
{{
"recommendation": "BUY|SELL|HOLD",
"confidence": 0-100,
"reasoning": "Your detailed analysis here",
"support_levels": [price1, price2, price3],
"resistance_levels": [price1, price2, price3],
"risk_level": "LOW|MEDIUM|HIGH"
}}
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": settings.OPENROUTER_SITE_URL,
"X-Title": settings.OPENROUTER_SITE_NAME,
}
payload = {
"model": self.model,
"messages": [
{
"role": "system",
"content": "You are a professional gold trading analyst. Always respond with valid JSON.",
},
{"role": "user", "content": prompt},
],
"temperature": 0.7,
"max_tokens": 1000,
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
# Extract AI response
ai_content = data["choices"][0]["message"]["content"]
# Parse JSON response
try:
# Try to extract JSON from markdown code blocks if present
if "```json" in ai_content:
json_start = ai_content.find("```json") + 7
json_end = ai_content.find("```", json_start)
ai_content = ai_content[json_start:json_end].strip()
elif "```" in ai_content:
json_start = ai_content.find("```") + 3
json_end = ai_content.find("```", json_start)
ai_content = ai_content[json_start:json_end].strip()
analysis_data = json.loads(ai_content)
except json.JSONDecodeError:
# Fallback to default response if JSON parsing fails
return AIAnalysisResponse(
recommendation=Recommendation.HOLD,
confidence=50.0,
reasoning="Unable to parse AI response. Please try again.",
support_resistance=SupportResistance(support=[], resistance=[]),
risk_level=RiskLevel.MEDIUM,
)
# Map to response schema
return AIAnalysisResponse(
recommendation=Recommendation(analysis_data.get("recommendation", "HOLD")),
confidence=float(analysis_data.get("confidence", 50)),
reasoning=analysis_data.get("reasoning", "Analysis completed."),
support_resistance=SupportResistance(
support=analysis_data.get("support_levels", []),
resistance=analysis_data.get("resistance_levels", []),
),
risk_level=RiskLevel(analysis_data.get("risk_level", "MEDIUM")),
)
openrouter_service = OpenRouterService()
+198
View File
@@ -0,0 +1,198 @@
import random
import time
from datetime import datetime, timedelta
from typing import List, Optional
from app.schemas.schemas import PriceData
class GoldPriceSimulator:
"""
Simulates realistic gold price movements without external API calls.
Uses Geometric Brownian Motion for realistic price action.
"""
def __init__(self, initial_price: float = 2650.0):
"""
Initialize the simulator with a starting price.
Args:
initial_price: Starting gold price in USD per oz (default ~current market price)
"""
self.base_price = initial_price
self.current_price = initial_price
self.volatility = 0.0008 # Daily volatility (0.08%)
self.drift = 0.00001 # Slight upward drift
self.last_update = time.time()
# For trend simulation
self.trend_direction = 1 # 1 for up, -1 for down
self.trend_strength = 0.0001
self.trend_duration = 0
self.max_trend_duration = 100 # Max ticks before trend change
def _calculate_price_change(self) -> float:
"""Calculate the next price change using Geometric Brownian Motion."""
# Random walk component
random_shock = random.gauss(0, 1) * self.volatility
# Trend component (changes periodically)
self.trend_duration += 1
if self.trend_duration > self.max_trend_duration:
# Change trend direction
self.trend_direction = random.choice([1, -1])
self.trend_strength = random.uniform(0.00005, 0.0002)
self.trend_duration = 0
self.max_trend_duration = random.randint(50, 200)
trend_component = self.trend_direction * self.trend_strength
# Mean reversion (pulls price back toward base)
mean_reversion = (self.base_price - self.current_price) * 0.00001
# Combine components
total_change = self.drift + random_shock + trend_component + mean_reversion
return self.current_price * total_change
def get_current_price(self) -> float:
"""Get the current simulated gold price."""
# Update price based on time elapsed
current_time = time.time()
time_elapsed = current_time - self.last_update
# Update price (simulating continuous price movement)
if time_elapsed > 0:
# Multiple small updates for smoother price action
updates = max(1, int(time_elapsed))
for _ in range(min(updates, 10)): # Cap at 10 updates to avoid huge jumps
price_change = self._calculate_price_change()
self.current_price += price_change
# Keep price within reasonable bounds (±20% from base)
self.current_price = max(
self.base_price * 0.8,
min(self.base_price * 1.2, self.current_price)
)
self.last_update = current_time
return round(self.current_price, 2)
def get_live_candle(self, interval: str = "1min") -> PriceData:
"""
Generate a live price candle for the current interval.
Args:
interval: Time interval (1min, 5min, 15min, 30min, 60min)
Returns:
PriceData object with OHLC values
"""
current_price = self.get_current_price()
# Map intervals to seconds
interval_map = {
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = interval_map.get(interval, 60)
current_time = int(time.time())
# Round up to next interval boundary to ensure newest timestamp
timestamp = ((current_time // interval_seconds) + 1) * interval_seconds
# Generate OHLC with small realistic variance
variance = current_price * 0.0005 # 0.05% variance
open_price = current_price + random.uniform(-variance, variance)
close_price = current_price + random.uniform(-variance, variance)
high_price = max(open_price, close_price) + random.uniform(0, variance)
low_price = min(open_price, close_price) - random.uniform(0, variance)
return PriceData(
time=timestamp,
open=round(open_price, 2),
high=round(high_price, 2),
low=round(low_price, 2),
close=round(close_price, 2),
)
def generate_historical_data(
self,
interval: str = "daily",
points: int = 100
) -> List[PriceData]:
"""
Generate historical price data using the simulator.
Args:
interval: Time interval (daily, 1min, 5min, etc.)
points: Number of data points to generate
Returns:
List of PriceData objects in chronological order
"""
# Map intervals to seconds
interval_map = {
"daily": 24 * 60 * 60,
"1min": 60,
"5min": 5 * 60,
"15min": 15 * 60,
"30min": 30 * 60,
"60min": 60 * 60,
}
interval_seconds = interval_map.get(interval, 24 * 60 * 60)
# Start from past and work forward
end_time = int(time.time())
start_time = end_time - (interval_seconds * points)
price_data = []
current_sim_price = self.base_price
for i in range(points):
timestamp = start_time + (interval_seconds * i)
# Simulate price evolution
price_change = random.gauss(0, 1) * self.volatility * current_sim_price
trend = random.uniform(-0.0001, 0.0001) * current_sim_price
current_sim_price += price_change + trend
# Keep within bounds
current_sim_price = max(
self.base_price * 0.85,
min(self.base_price * 1.15, current_sim_price)
)
# Generate OHLC for this candle
candle_variance = current_sim_price * 0.002 # 0.2% intra-candle variance
open_price = current_sim_price + random.uniform(-candle_variance/2, candle_variance/2)
close_price = current_sim_price + random.uniform(-candle_variance/2, candle_variance/2)
high_price = max(open_price, close_price) + random.uniform(0, candle_variance)
low_price = min(open_price, close_price) - random.uniform(0, candle_variance)
price_data.append(
PriceData(
time=timestamp,
open=round(open_price, 2),
high=round(high_price, 2),
low=round(low_price, 2),
close=round(close_price, 2),
)
)
# Set current price to the last closing price for continuity
if price_data:
self.current_price = price_data[-1].close
self.last_update = time.time()
return price_data
# Global simulator instance (maintains state across requests)
gold_simulator = GoldPriceSimulator(initial_price=2650.0)
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any, Dict, List
_TEMPLATES: Dict[str, Dict[str, Any]] = {
"analysis_default": {
"name": "analysis_default",
"description": "General market analysis prompt with technicals and news context",
"variables": ["symbol", "timeframe", "recent_news", "technicals"],
"body": (
"You are a trading assistant. Analyze {{symbol}} on {{timeframe}} timeframe.\n"
"Consider technical signals: {{technicals}} and relevant news: {{recent_news}}.\n"
"Provide a concise recommendation (BUY/SELL/HOLD) with reasoning and risk notes."
),
},
"risk_control_default": {
"name": "risk_control_default",
"description": "Risk control instructions for planning",
"variables": ["max_position_fraction", "min_rr_ratio"],
"body": (
"Adhere to risk rules: position <= {{max_position_fraction}} of equity,"
" risk-reward ratio >= {{min_rr_ratio}} whenever applicable."
),
},
}
def list_templates() -> List[Dict[str, Any]]:
return [
{"name": t["name"], "description": t["description"], "variables": t["variables"]}
for t in _TEMPLATES.values()
]
def get_template(name: str) -> Dict[str, Any]:
if name not in _TEMPLATES:
raise KeyError("Template not found")
return _TEMPLATES[name]
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any, Dict
# Simple risk rules for MVP
MAX_POSITION_FRACTION = 0.6 # max 60% of equity in a single position
def _equity(sim_state: Dict[str, Any], price: float) -> float:
cash = float(sim_state.get("cash", 0.0))
pos = sim_state.get("position")
qty = float(pos["quantity"]) if pos else 0.0
return cash + qty * price
def validate_order(sim_state: Dict[str, Any], action: str, quantity: float, price: float) -> None:
action = str(action).upper()
if quantity <= 0 or price <= 0:
raise ValueError("Quantity and price must be positive")
if action == "BUY":
# Anti-stacking: only one symbol supported in MVP, allow averaging up to cap
pos = sim_state.get("position")
current_qty = float(pos["quantity"]) if pos else 0.0
new_qty = current_qty + float(quantity)
resulting_position_value = new_qty * float(price)
eq_now = _equity(sim_state, price)
if eq_now <= 0:
raise ValueError("Equity must be positive")
if resulting_position_value > MAX_POSITION_FRACTION * eq_now:
raise ValueError("Position exceeds max allowed exposure fraction")
elif action == "SELL":
pos = sim_state.get("position")
if not pos or float(quantity) > float(pos.get("quantity", 0.0)):
raise ValueError("Insufficient position to sell")
else:
raise ValueError("Unsupported action")
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any, Dict
from app.config import settings
_state: Dict[str, Any] = {
"models": {
"default_model": settings.OPENROUTER_MODEL,
"temperature": 0.3,
"max_tokens": 800,
},
"exchanges": {
"binance": {"enabled": True},
"alpha_vantage": {
"enabled": True,
"has_api_key": bool(settings.ALPHA_VANTAGE_API_KEY),
},
},
}
def get_models() -> Dict[str, Any]:
return dict(_state["models"]) # shallow copy
def update_models(patch: Dict[str, Any]) -> Dict[str, Any]:
allowed = {"default_model", "temperature", "max_tokens"}
for k, v in patch.items():
if k in allowed:
_state["models"][k] = v
return get_models()
def get_exchanges() -> Dict[str, Any]:
return dict(_state["exchanges"]) # shallow copy
def update_exchanges(patch: Dict[str, Any]) -> Dict[str, Any]:
# Shallow merge per top-level key
for k, v in patch.items():
if k in _state["exchanges"] and isinstance(v, dict):
_state["exchanges"][k].update(v)
return get_exchanges()