Add configurable data providers and localize frontend UI
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Polling-based Price Manager - Uses Finnhub REST API
|
||||
Supports real-time price fetching via polling
|
||||
Polling-based Price Manager with provider-aware quote polling.
|
||||
Supports Finnhub and yfinance for near real-time price fetching.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
@@ -9,22 +9,35 @@ import time
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import finnhub
|
||||
import yfinance as yf
|
||||
from backend.data.provider_utils import normalize_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PollingPriceManager:
|
||||
"""Polling-based price manager using Finnhub Quote API"""
|
||||
"""Polling-based price manager using Finnhub or yfinance."""
|
||||
|
||||
def __init__(self, api_key: str, poll_interval: int = 30):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
poll_interval: int = 30,
|
||||
provider: str = "finnhub",
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Finnhub API Key
|
||||
poll_interval: Polling interval in seconds (default 30s)
|
||||
provider: Quote provider (`finnhub` or `yfinance`)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.poll_interval = poll_interval
|
||||
self.finnhub_client = finnhub.Client(api_key=api_key)
|
||||
self.provider = provider
|
||||
self.finnhub_client = (
|
||||
finnhub.Client(api_key=api_key)
|
||||
if provider == "finnhub" and api_key
|
||||
else None
|
||||
)
|
||||
|
||||
self.subscribed_symbols: List[str] = []
|
||||
self.latest_prices: Dict[str, float] = {}
|
||||
@@ -35,12 +48,14 @@ class PollingPriceManager:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
logger.info(
|
||||
f"PollingPriceManager initialized (interval: {poll_interval}s)",
|
||||
"PollingPriceManager initialized "
|
||||
f"(provider: {provider}, interval: {poll_interval}s)",
|
||||
)
|
||||
|
||||
def subscribe(self, symbols: List[str]):
|
||||
"""Subscribe to stock symbols"""
|
||||
for symbol in symbols:
|
||||
symbol = normalize_symbol(symbol)
|
||||
if symbol not in self.subscribed_symbols:
|
||||
self.subscribed_symbols.append(symbol)
|
||||
logger.info(f"Subscribed to: {symbol}")
|
||||
@@ -48,6 +63,7 @@ class PollingPriceManager:
|
||||
def unsubscribe(self, symbols: List[str]):
|
||||
"""Unsubscribe from symbols"""
|
||||
for symbol in symbols:
|
||||
symbol = normalize_symbol(symbol)
|
||||
if symbol in self.subscribed_symbols:
|
||||
self.subscribed_symbols.remove(symbol)
|
||||
logger.info(f"Unsubscribed: {symbol}")
|
||||
@@ -60,7 +76,7 @@ class PollingPriceManager:
|
||||
"""Fetch latest prices for all subscribed stocks"""
|
||||
for symbol in self.subscribed_symbols:
|
||||
try:
|
||||
quote_data = self.finnhub_client.quote(symbol)
|
||||
quote_data = self._fetch_quote(symbol)
|
||||
|
||||
current_price = quote_data.get("c")
|
||||
open_price = quote_data.get("o")
|
||||
@@ -114,6 +130,67 @@ class PollingPriceManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch {symbol} price: {e}")
|
||||
|
||||
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
||||
"""Fetch a normalized quote payload from the configured provider."""
|
||||
if self.provider == "yfinance":
|
||||
return self._fetch_yfinance_quote(symbol)
|
||||
if not self.finnhub_client:
|
||||
raise ValueError("Finnhub API key required for finnhub polling")
|
||||
return self.finnhub_client.quote(symbol)
|
||||
|
||||
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
||||
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||
ticker = yf.Ticker(symbol)
|
||||
fast_info = dict(getattr(ticker, "fast_info", {}) or {})
|
||||
|
||||
current_price = _coerce_float(
|
||||
fast_info.get("lastPrice") or fast_info.get("regularMarketPrice"),
|
||||
)
|
||||
open_price = _coerce_float(
|
||||
fast_info.get("open") or fast_info.get("regularMarketOpen"),
|
||||
)
|
||||
previous_close = _coerce_float(
|
||||
fast_info.get("previousClose")
|
||||
or fast_info.get("regularMarketPreviousClose"),
|
||||
)
|
||||
high_price = _coerce_float(
|
||||
fast_info.get("dayHigh") or fast_info.get("regularMarketDayHigh"),
|
||||
)
|
||||
low_price = _coerce_float(
|
||||
fast_info.get("dayLow") or fast_info.get("regularMarketDayLow"),
|
||||
)
|
||||
|
||||
if current_price is None:
|
||||
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
||||
if history.empty:
|
||||
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||
latest = history.iloc[-1]
|
||||
current_price = _coerce_float(latest.get("Close"))
|
||||
open_price = open_price or _coerce_float(history.iloc[0].get("Open"))
|
||||
high_price = high_price or _coerce_float(history["High"].max())
|
||||
low_price = low_price or _coerce_float(history["Low"].min())
|
||||
|
||||
if current_price is None:
|
||||
raise ValueError(f"{symbol}: Invalid yfinance quote data")
|
||||
|
||||
effective_open = open_price or previous_close or current_price
|
||||
effective_prev_close = previous_close or effective_open or current_price
|
||||
change = current_price - effective_prev_close
|
||||
change_percent = (
|
||||
(change / effective_prev_close) * 100 if effective_prev_close else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"c": current_price,
|
||||
"o": effective_open,
|
||||
"h": high_price or max(current_price, effective_open),
|
||||
"l": low_price or min(current_price, effective_open),
|
||||
"pc": effective_prev_close,
|
||||
"d": change,
|
||||
"dp": change_percent,
|
||||
"t": int(time.time()),
|
||||
}
|
||||
|
||||
def _polling_loop(self):
|
||||
"""Main polling loop"""
|
||||
logger.info(f"Price polling started (interval: {self.poll_interval}s)")
|
||||
@@ -173,3 +250,12 @@ class PollingPriceManager:
|
||||
"""Reset open prices for new trading day"""
|
||||
self.open_prices.clear()
|
||||
logger.info("Open prices reset")
|
||||
|
||||
|
||||
def _coerce_float(value) -> Optional[float]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user