feat: Add Phase 4 advanced metrics and components
- Add advanced metrics dashboard with trade analytics - Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.) - Add strategy mode selector and trend confirmation - Add risk automation panel and slippage correlation analysis - Add daily trading plan enhancements with modal components - Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics) - Add broker service integration and trading API - Add test setup and vitest configuration - Include parquet data files for live market data - Add comprehensive documentation in docs/ folder
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's CSV data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
Uses their CSV data API: https://chart-data.bullionvault.com
|
||||
"""
|
||||
|
||||
# Correct BullionVault CSV API base URL
|
||||
BASE_URL = "https://chart-data.bullionvault.com"
|
||||
|
||||
# Metal codes
|
||||
METALS = {
|
||||
'gold': 'AUX',
|
||||
'silver': 'AGX',
|
||||
'platinum': 'PTX',
|
||||
'palladium': 'PDX'
|
||||
}
|
||||
|
||||
# Interval codes (seconds between data points)
|
||||
INTERVALS = {
|
||||
'10m': 5, # 10 minutes
|
||||
'1h': 15, # 1 hour
|
||||
'6h': 120, # 6 hours
|
||||
'1d': 600, # 1 day (default)
|
||||
'1w': 3600, # 1 week
|
||||
'1m': 14400, # 1 month
|
||||
'3m': 43200, # 3 months (1 quarter)
|
||||
'1y': 172800, # 1 year
|
||||
'5y': 864000, # 5 years
|
||||
'20y': 2592000 # 20 years
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_backoff_seconds: float = 0.5,
|
||||
) -> None:
|
||||
self.base_url = base_url or self.BASE_URL
|
||||
self.max_retries = max(1, max_retries)
|
||||
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
|
||||
|
||||
if client is None:
|
||||
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||
self._owns_client = True
|
||||
else:
|
||||
self.client = client
|
||||
self._owns_client = False
|
||||
|
||||
async def __aenter__(self) -> "BullionVaultService":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch CSV data from BullionVault
|
||||
# Format: /prices/CSV/{metal}/{currency}/{interval}/Full
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS['1d']
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
if not price_data:
|
||||
raise ValueError("No price data available from BullionVault")
|
||||
|
||||
# Get latest price (first row after header)
|
||||
latest = price_data[0]
|
||||
|
||||
# Calculate daily statistics
|
||||
oz_prices = [row['oz_close'] for row in price_data if row['oz_close'] is not None]
|
||||
|
||||
if not oz_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest['oz_close']
|
||||
daily_high = max([row['oz_high'] for row in price_data if row['oz_high'] is not None])
|
||||
daily_low = min([row['oz_low'] for row in price_data if row['oz_low'] is not None])
|
||||
|
||||
# Calculate change from last data point
|
||||
first_price = price_data[-1]['oz_close'] if len(price_data) > 1 else current_price
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
timestamp = latest['timestamp']
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"price_kg": round(latest['kg_close'], 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(price_data)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def _fetch_csv(self, path: str) -> str:
|
||||
"""Fetch CSV data from BullionVault with simple retry logic."""
|
||||
|
||||
last_exception: Optional[Exception] = None
|
||||
base = self.base_url.rstrip("/")
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
url = path if path.startswith("http") else f"{base}{path}"
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
csv_text = response.text.strip()
|
||||
if not csv_text:
|
||||
raise ValueError("BullionVault returned empty response body")
|
||||
|
||||
logger.debug(
|
||||
"Fetched BullionVault CSV successfully",
|
||||
extra={"path": url, "attempt": attempt},
|
||||
)
|
||||
return csv_text
|
||||
|
||||
except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc:
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
"BullionVault CSV fetch attempt failed",
|
||||
extra={
|
||||
"path": url if "url" in locals() else path,
|
||||
"attempt": attempt,
|
||||
"max_attempts": self.max_retries,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
await asyncio.sleep(self.retry_backoff_seconds * attempt)
|
||||
|
||||
assert last_exception is not None
|
||||
raise last_exception
|
||||
|
||||
def _parse_csv(self, csv_text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse BullionVault CSV response
|
||||
|
||||
CSV format:
|
||||
"Date",High (kg),Low (kg),Close (kg),,High (troy oz),Low (troy oz),Close (troy oz),
|
||||
"05:10:00 23-Nov-2025",130702.99,130702.99,130702.99,,4065.32,4065.32,4065.32,
|
||||
|
||||
Args:
|
||||
csv_text: Raw CSV text from BullionVault
|
||||
|
||||
Returns:
|
||||
List of price dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.reader(io.StringIO(csv_text))
|
||||
|
||||
# Skip header
|
||||
next(reader, None)
|
||||
|
||||
for row in reader:
|
||||
if len(row) < 8:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Parse date/time: "HH:MM:SS DD-Mon-YYYY"
|
||||
date_str = row[0].strip('"')
|
||||
timestamp = datetime.strptime(date_str, "%H:%M:%S %d-%b-%Y").replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract prices (kg and oz)
|
||||
kg_high = self._to_float(row[1])
|
||||
kg_low = self._to_float(row[2])
|
||||
kg_close = self._to_float(row[3])
|
||||
|
||||
oz_high = self._to_float(row[5])
|
||||
oz_low = self._to_float(row[6])
|
||||
oz_close = self._to_float(row[7])
|
||||
|
||||
result.append({
|
||||
'timestamp': timestamp,
|
||||
'kg_high': kg_high,
|
||||
'kg_low': kg_low,
|
||||
'kg_close': kg_close,
|
||||
'oz_high': oz_high,
|
||||
'oz_low': oz_low,
|
||||
'oz_close': oz_close
|
||||
})
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Skipping malformed CSV row: {row} - {e}")
|
||||
continue
|
||||
|
||||
result.sort(key=lambda entry: entry['timestamp'], reverse=True)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Optional[str]) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 3m, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS.get(timeframe, self.INTERVALS['1d'])
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(price_data) > limit:
|
||||
price_data = price_data[:limit]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in price_data:
|
||||
result.append({
|
||||
"timestamp": point['timestamp'].isoformat(),
|
||||
"time": int(point['timestamp'].timestamp()),
|
||||
"open": point['oz_close'], # BullionVault doesn't provide open, use close
|
||||
"high": point['oz_high'],
|
||||
"low": point['oz_low'],
|
||||
"close": point['oz_close'],
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
if self._owns_client:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's chart data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=15.0)
|
||||
self.base_url = "https://www.bullionvault.com"
|
||||
# BullionVault chart data endpoint
|
||||
self.chart_data_url = f"{self.base_url}/chart/chart-data.json"
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch latest gold price data
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": "1d", # 1 day for recent data
|
||||
"chartType": "line"
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
raise ValueError("Invalid response from BullionVault")
|
||||
|
||||
prices = data["prices"]
|
||||
if not prices:
|
||||
raise ValueError("No price data available")
|
||||
|
||||
# Get latest price point
|
||||
latest = prices[-1]
|
||||
|
||||
# Calculate daily statistics
|
||||
daily_prices = [p[1] for p in prices if p[1] is not None]
|
||||
|
||||
if not daily_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest[1] # Price per ounce
|
||||
daily_high = max(daily_prices)
|
||||
daily_low = min(daily_prices)
|
||||
|
||||
# Calculate change from first price of day
|
||||
first_price = prices[0][1]
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
# Convert timestamp (BullionVault uses milliseconds)
|
||||
timestamp_ms = latest[0]
|
||||
timestamp = datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"timestamp_ms": timestamp_ms,
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(prices)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 1q, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": timeframe,
|
||||
"chartType": "hlc" # High-Low-Close for OHLC data
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
return []
|
||||
|
||||
prices = data["prices"]
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(prices) > limit:
|
||||
prices = prices[-limit:]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in prices:
|
||||
if len(point) >= 4: # [timestamp, open, high, low, close]
|
||||
timestamp_ms = point[0]
|
||||
result.append({
|
||||
"timestamp": datetime.fromtimestamp(timestamp_ms / 1000.0).isoformat(),
|
||||
"time": int(timestamp_ms / 1000),
|
||||
"open": float(point[1]) if point[1] is not None else 0.0,
|
||||
"high": float(point[2]) if point[2] is not None else 0.0,
|
||||
"low": float(point[3]) if point[3] is not None else 0.0,
|
||||
"close": float(point[4]) if len(point) > 4 and point[4] is not None else float(point[1]),
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Robust Gold Price Fetcher with Multiple Data Sources and Fallback
|
||||
Ensures accurate real-time gold pricing with redundancy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoldPriceFetcher:
|
||||
"""
|
||||
Multi-source gold price fetcher with automatic fallback
|
||||
|
||||
Data Sources (in priority order):
|
||||
1. Alpha Vantage - GLD ETF (reliable, free tier)
|
||||
2. Twelve Data API (if available)
|
||||
3. Yahoo Finance (backup)
|
||||
4. Static fallback to reasonable estimate
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=10.0)
|
||||
# GLD ETF tracks ~1/10th of gold spot price
|
||||
self.gld_multiplier = 10.0
|
||||
# Gold futures (GC) are 100oz contracts, but quote is per oz
|
||||
self.gc_multiplier = 1.0
|
||||
|
||||
async def get_current_gold_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold price with automatic fallback through multiple sources
|
||||
|
||||
Returns:
|
||||
Dict with: price, source, timestamp, high_24h, low_24h, change_percent
|
||||
"""
|
||||
# Try Alpha Vantage GLD first (most reliable)
|
||||
try:
|
||||
result = await self._fetch_from_alpha_vantage_gld()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Alpha Vantage GLD: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Alpha Vantage GLD failed: {e}")
|
||||
|
||||
# Try Twelve Data if available
|
||||
try:
|
||||
result = await self._fetch_from_twelve_data()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Twelve Data: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Twelve Data failed: {e}")
|
||||
|
||||
# Try alternative free sources
|
||||
try:
|
||||
result = await self._fetch_from_metals_api()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Metals-API: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Metals-API failed: {e}")
|
||||
|
||||
# Last resort: return estimated price with warning
|
||||
logger.error("⚠️ All gold price sources failed, using estimated price")
|
||||
return self._get_fallback_price()
|
||||
|
||||
async def _fetch_from_alpha_vantage_gld(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Alpha Vantage using GLD ETF as proxy
|
||||
GLD tracks gold at ~1/10th spot price
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=GLD&apikey={api_key}"
|
||||
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "Global Quote" not in data or not data["Global Quote"]:
|
||||
return None
|
||||
|
||||
quote = data["Global Quote"]
|
||||
gld_price = float(quote.get("05. price", 0))
|
||||
|
||||
if gld_price == 0:
|
||||
return None
|
||||
|
||||
# Convert GLD price to gold spot price (multiply by 10)
|
||||
gold_price = gld_price * self.gld_multiplier
|
||||
|
||||
return {
|
||||
"price": gold_price,
|
||||
"open": float(quote.get("02. open", 0)) * self.gld_multiplier,
|
||||
"high": float(quote.get("03. high", 0)) * self.gld_multiplier,
|
||||
"low": float(quote.get("04. low", 0)) * self.gld_multiplier,
|
||||
"volume": int(quote.get("06. volume", 0)),
|
||||
"previous_close": float(quote.get("08. previous close", 0)) * self.gld_multiplier,
|
||||
"change": float(quote.get("09. change", 0)) * self.gld_multiplier,
|
||||
"change_percent": quote.get("10. change percent", "0%"),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "Alpha Vantage (GLD ETF)",
|
||||
"trading_day": quote.get("07. latest trading day", ""),
|
||||
}
|
||||
|
||||
async def _fetch_from_twelve_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Twelve Data API (if API key available)
|
||||
They have direct XAU/USD forex pair
|
||||
"""
|
||||
# Twelve Data would require API key setup
|
||||
# Placeholder for now
|
||||
return None
|
||||
|
||||
async def _fetch_from_metals_api(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Metals-API.com free tier
|
||||
Provides direct gold spot prices
|
||||
"""
|
||||
try:
|
||||
# Free tier endpoint (limited requests)
|
||||
url = "https://metals-api.com/api/latest"
|
||||
params = {
|
||||
"access_key": "your_key_here", # Would need API key
|
||||
"base": "USD",
|
||||
"symbols": "XAU"
|
||||
}
|
||||
|
||||
# Skip if no key configured
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_fallback_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return reasonable estimated gold price when all sources fail
|
||||
Based on typical 2025 gold trading range
|
||||
"""
|
||||
# Conservative estimate for late 2025 gold prices
|
||||
estimated_price = 3800.0 # Mid-range estimate
|
||||
|
||||
return {
|
||||
"price": estimated_price,
|
||||
"open": estimated_price,
|
||||
"high": estimated_price * 1.01,
|
||||
"low": estimated_price * 0.99,
|
||||
"volume": 0,
|
||||
"previous_close": estimated_price,
|
||||
"change": 0.0,
|
||||
"change_percent": "0%",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "FALLBACK_ESTIMATE",
|
||||
"trading_day": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
"warning": "⚠️ Using estimated price - all data sources unavailable"
|
||||
}
|
||||
|
||||
async def get_intraday_data(self, interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get intraday gold price data
|
||||
|
||||
Args:
|
||||
interval: Time interval (1min, 5min, 15min, 30min, 60min)
|
||||
limit: Number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLCV data points
|
||||
"""
|
||||
try:
|
||||
return await self._fetch_intraday_alpha_vantage(interval, limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch intraday data: {e}")
|
||||
return []
|
||||
|
||||
async def _fetch_intraday_alpha_vantage(self, interval: str, limit: int) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch intraday data from Alpha Vantage
|
||||
Using GLD as proxy since XAU/USD intraday is premium
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query"
|
||||
params = {
|
||||
"function": "TIME_SERIES_INTRADAY",
|
||||
"symbol": "GLD",
|
||||
"interval": interval,
|
||||
"apikey": api_key,
|
||||
"outputsize": "compact" # Last 100 data points
|
||||
}
|
||||
|
||||
response = await self.client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
time_series_key = f"Time Series ({interval})"
|
||||
if time_series_key not in data:
|
||||
return []
|
||||
|
||||
time_series = data[time_series_key]
|
||||
|
||||
# Convert to OHLCV format and apply gold multiplier
|
||||
result = []
|
||||
for timestamp, values in list(time_series.items())[:limit]:
|
||||
result.append({
|
||||
"timestamp": timestamp,
|
||||
"time": int(datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()),
|
||||
"open": float(values["1. open"]) * self.gld_multiplier,
|
||||
"high": float(values["2. high"]) * self.gld_multiplier,
|
||||
"low": float(values["3. low"]) * self.gld_multiplier,
|
||||
"close": float(values["4. close"]) * self.gld_multiplier,
|
||||
"volume": int(values["5. volume"]),
|
||||
})
|
||||
|
||||
return sorted(result, key=lambda x: x["time"])
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
gold_price_fetcher = GoldPriceFetcher()
|
||||
|
||||
|
||||
# Convenience functions for backward compatibility
|
||||
async def get_current_gold_price() -> Dict[str, Any]:
|
||||
"""Get current gold spot price"""
|
||||
return await gold_price_fetcher.get_current_gold_price()
|
||||
|
||||
|
||||
async def get_gold_intraday(interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""Get intraday gold price data"""
|
||||
return await gold_price_fetcher.get_intraday_data(interval, limit)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
GOLDPRICE_URL_TEMPLATE = "https://data-asg.goldprice.org/dbXRates/{currency}"
|
||||
DEFAULT_CURRENCY = "USD"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_goldprice_quote(currency: str = DEFAULT_CURRENCY) -> Optional[dict]:
|
||||
url = GOLDPRICE_URL_TEMPLATE.format(currency=currency.upper())
|
||||
async with httpx.AsyncClient(timeout=10.0, headers=HEADERS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return None
|
||||
|
||||
quote = items[0]
|
||||
xau_price = quote.get("xauPrice")
|
||||
if xau_price is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"price": float(xau_price),
|
||||
"change": float(quote.get("chgXau") or 0.0),
|
||||
"change_percent": float(quote.get("pcXau") or 0.0),
|
||||
"previous_close": float(quote.get("xauClose") or 0.0),
|
||||
"timestamp_ms": int(data.get("ts") or 0),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YAHOO_QUOTE_URL = "https://query1.finance.yahoo.com/v7/finance/quote"
|
||||
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
YAHOO_SYMBOL = "XAUUSD=X"
|
||||
YAHOO_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_yahoo_quote(symbol: str = YAHOO_SYMBOL) -> Optional[dict]:
|
||||
params = {"symbols": symbol}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(YAHOO_QUOTE_URL, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result = (data.get("quoteResponse", {}) or {}).get("result", [])
|
||||
if not result:
|
||||
return None
|
||||
quote = result[0]
|
||||
def _safe_float(value: Optional[float], default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"price": _safe_float(quote.get("regularMarketPrice"), default=0.0),
|
||||
"high": _safe_float(quote.get("regularMarketDayHigh")),
|
||||
"low": _safe_float(quote.get("regularMarketDayLow")),
|
||||
"volume": _safe_float(quote.get("regularMarketVolume"), default=0.0),
|
||||
"previous_close": _safe_float(quote.get("regularMarketPreviousClose"), default=0.0),
|
||||
"timestamp": int(quote.get("regularMarketTime") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _interval_range_for_chart(interval: str) -> tuple[str, str]:
|
||||
normalized = interval.lower()
|
||||
mapping = {
|
||||
"1m": ("1m", "1d"),
|
||||
"1min": ("1m", "1d"),
|
||||
"5m": ("5m", "5d"),
|
||||
"5min": ("5m", "5d"),
|
||||
"15m": ("15m", "1mo"),
|
||||
"15min": ("15m", "1mo"),
|
||||
"30m": ("30m", "1mo"),
|
||||
"30min": ("30m", "1mo"),
|
||||
"60m": ("60m", "1y"),
|
||||
"60min": ("60m", "1y"),
|
||||
"daily": ("1d", "5y"),
|
||||
}
|
||||
return mapping.get(normalized, ("1m", "1d"))
|
||||
|
||||
|
||||
async def fetch_yahoo_ohlcv(symbol: str = YAHOO_SYMBOL, interval: str = "1m") -> List[PriceData]:
|
||||
interval_key, range_key = _interval_range_for_chart(interval)
|
||||
url = YAHOO_CHART_URL.format(symbol=symbol)
|
||||
params = {"interval": interval_key, "range": range_key, "includePrePost": "false"}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
chart = (data.get("chart") or {}).get("result") or []
|
||||
if not chart:
|
||||
return []
|
||||
result = chart[0]
|
||||
timestamps = result.get("timestamp") or []
|
||||
indicators = (result.get("indicators") or {}).get("quote") or []
|
||||
if not indicators:
|
||||
return []
|
||||
quote = indicators[0]
|
||||
opens = quote.get("open") or []
|
||||
highs = quote.get("high") or []
|
||||
lows = quote.get("low") or []
|
||||
closes = quote.get("close") or []
|
||||
volumes = quote.get("volume") or []
|
||||
|
||||
price_data: List[PriceData] = []
|
||||
for idx, ts in enumerate(timestamps):
|
||||
open_price = opens[idx] if idx < len(opens) else None
|
||||
high_price = highs[idx] if idx < len(highs) else None
|
||||
low_price = lows[idx] if idx < len(lows) else None
|
||||
close_price = closes[idx] if idx < len(closes) else None
|
||||
if None in (open_price, high_price, low_price, close_price):
|
||||
continue
|
||||
volume_val = volumes[idx] if idx < len(volumes) else 0.0
|
||||
price_data.append(
|
||||
PriceData(
|
||||
time=int(ts),
|
||||
open=float(open_price),
|
||||
high=float(high_price),
|
||||
low=float(low_price),
|
||||
close=float(close_price),
|
||||
volume=float(volume_val or 0.0),
|
||||
)
|
||||
)
|
||||
return price_data
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YA_SYMBOL = "XAUUSD=X"
|
||||
|
||||
|
||||
def _format_dataframe(df: pd.DataFrame) -> List[PriceData]:
|
||||
rows: List[PriceData] = []
|
||||
if df.empty:
|
||||
return rows
|
||||
df = df.dropna(subset=["Open", "High", "Low", "Close"])
|
||||
for idx, row in df.iterrows():
|
||||
timestamp = int(pd.Timestamp(idx).timestamp())
|
||||
rows.append(
|
||||
PriceData(
|
||||
time=timestamp,
|
||||
open=float(row["Open"]),
|
||||
high=float(row["High"]),
|
||||
low=float(row["Low"]),
|
||||
close=float(row["Close"]),
|
||||
volume=float(row.get("Volume", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def fetch_yfinance_history(
|
||||
symbol: str = YA_SYMBOL,
|
||||
interval: str = "1m",
|
||||
period: str = "1d",
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
) -> List[PriceData]:
|
||||
def _download() -> pd.DataFrame:
|
||||
return yf.download(
|
||||
symbol,
|
||||
interval=interval,
|
||||
period=None if start else period,
|
||||
start=start,
|
||||
end=end,
|
||||
progress=False,
|
||||
auto_adjust=False,
|
||||
threads=False,
|
||||
)
|
||||
|
||||
df = await asyncio.to_thread(_download)
|
||||
return _format_dataframe(df)
|
||||
|
||||
|
||||
async def fetch_yfinance_quote(symbol: str = YA_SYMBOL) -> Optional[dict]:
|
||||
rows = await fetch_yfinance_history(symbol=symbol, interval="1m", period="1d")
|
||||
if not rows:
|
||||
return None
|
||||
latest = rows[-1]
|
||||
previous = rows[-2] if len(rows) > 1 else latest
|
||||
return {
|
||||
"price": latest.close,
|
||||
"previous_close": previous.close,
|
||||
"high_24h": max(r.high for r in rows[-1440:]),
|
||||
"low_24h": min(r.low for r in rows[-1440:]),
|
||||
"volume": latest.volume or 0.0,
|
||||
"updated_at": latest.time,
|
||||
"rows": rows,
|
||||
}
|
||||
Reference in New Issue
Block a user