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:
Krikorios
2025-11-27 10:23:58 +02:00
parent b5e2b02cb8
commit 48e60d015f
2019 changed files with 39793 additions and 257 deletions
@@ -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)
+37
View File
@@ -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),
}
+105
View File
@@ -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,
}