Merge pull request #96 from johan-martensson/fix/financial-batch-fetch

fix: replace concurrent yfinance fetches with single batch download
This commit is contained in:
Shadowbroker
2026-03-22 01:48:14 -06:00
committed by GitHub
2 changed files with 99 additions and 139 deletions
+26 -105
View File
@@ -13,23 +13,19 @@ Heavy logic has been extracted into services/fetchers/:
- infrastructure.py — internet outages, data centers, CCTV, KiwiSDR - infrastructure.py — internet outages, data centers, CCTV, KiwiSDR
- geo.py — ships, airports, frontlines, GDELT, LiveUAMap - geo.py — ships, airports, frontlines, GDELT, LiveUAMap
""" """
import logging import logging
import concurrent.futures import concurrent.futures
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime
from services.cctv_pipeline import init_db from services.cctv_pipeline import init_db
# Shared state — all fetcher modules read/write through this # Shared state — all fetcher modules read/write through this
from services.fetchers._store import ( from services.fetchers._store import (
latest_data, latest_data, source_timestamps, _mark_fresh, _data_lock, # noqa: F401 — re-exported for main.py
source_timestamps,
_mark_fresh,
_data_lock, # noqa: F401 — re-exported for main.py
) )
# Domain-specific fetcher modules (already extracted) # Domain-specific fetcher modules (already extracted)
@@ -42,65 +38,19 @@ from services.fetchers.news import fetch_news # noqa: F401
# Newly extracted fetcher modules # Newly extracted fetcher modules
from services.fetchers.financial import fetch_defense_stocks, fetch_oil_prices # noqa: F401 from services.fetchers.financial import fetch_defense_stocks, fetch_oil_prices # noqa: F401
from services.fetchers.earth_observation import ( # noqa: F401 from services.fetchers.earth_observation import ( # noqa: F401
fetch_earthquakes, fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
fetch_firms_fires,
fetch_space_weather,
fetch_weather,
) )
from services.fetchers.infrastructure import ( # noqa: F401 from services.fetchers.infrastructure import ( # noqa: F401
fetch_internet_outages, fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_power_plants,
fetch_datacenters, fetch_cctv, fetch_kiwisdr,
fetch_military_bases,
fetch_power_plants,
fetch_cctv,
fetch_kiwisdr,
) )
from services.fetchers.geo import ( # noqa: F401 from services.fetchers.geo import ( # noqa: F401
fetch_ships, fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
fetch_airports, fetch_frontlines, fetch_gdelt, fetch_geopolitics, update_liveuamap,
find_nearest_airport,
cached_airports,
fetch_frontlines,
fetch_gdelt,
fetch_geopolitics,
update_liveuamap,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_cctv_ingest_cycle():
"""Populate the CCTV database, then refresh the in-memory cache."""
logger.info("CCTV ingest cycle starting...")
from services.cctv_pipeline import (
AustinTXIngestor,
LTASingaporeIngestor,
MadridCCTVIngestor,
MalagaCCTVIngestor,
NYCDOTIngestor,
SpainDGTIngestor,
TFLJamCamIngestor,
VigoCCTVIngestor,
VitoriaGasteizCCTVIngestor,
)
for ingestor_cls in (
TFLJamCamIngestor,
LTASingaporeIngestor,
AustinTXIngestor,
NYCDOTIngestor,
SpainDGTIngestor,
MadridCCTVIngestor,
MalagaCCTVIngestor,
VigoCCTVIngestor,
VitoriaGasteizCCTVIngestor,
):
ingestor_cls().ingest()
fetch_cctv()
logger.info("CCTV ingest cycle complete.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scheduler & Orchestration # Scheduler & Orchestration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -117,10 +67,9 @@ def update_fast_data():
futures = [executor.submit(func) for func in fast_funcs] futures = [executor.submit(func) for func in fast_funcs]
concurrent.futures.wait(futures) concurrent.futures.wait(futures)
with _data_lock: with _data_lock:
latest_data["last_updated"] = datetime.utcnow().isoformat() latest_data['last_updated'] = datetime.utcnow().isoformat()
logger.info("Fast-tier update complete.") logger.info("Fast-tier update complete.")
def update_slow_data(): def update_slow_data():
"""Slow-tier: contextual + enrichment data that refreshes less often (every 510 min).""" """Slow-tier: contextual + enrichment data that refreshes less often (every 510 min)."""
logger.info("Slow-tier data update starting...") logger.info("Slow-tier data update starting...")
@@ -146,7 +95,6 @@ def update_slow_data():
concurrent.futures.wait(futures) concurrent.futures.wait(futures)
logger.info("Slow-tier update complete.") logger.info("Slow-tier update complete.")
def update_all_data(): def update_all_data():
"""Full refresh — all tiers run IN PARALLEL for fastest startup.""" """Full refresh — all tiers run IN PARALLEL for fastest startup."""
logger.info("Full data update starting (parallel)...") logger.info("Full data update starting (parallel)...")
@@ -157,73 +105,46 @@ def update_all_data():
concurrent.futures.wait([f0, f1, f2]) concurrent.futures.wait([f0, f1, f2])
logger.info("Full data update complete.") logger.info("Full data update complete.")
_scheduler = None _scheduler = None
def start_scheduler(): def start_scheduler():
global _scheduler global _scheduler
init_db() init_db()
_scheduler = BackgroundScheduler(daemon=True) _scheduler = BackgroundScheduler(daemon=True)
# Fast tier — every 60 seconds # Fast tier — every 60 seconds
_scheduler.add_job( _scheduler.add_job(update_fast_data, 'interval', seconds=60, id='fast_tier', max_instances=1, misfire_grace_time=30)
update_fast_data,
"interval",
seconds=60,
id="fast_tier",
max_instances=1,
misfire_grace_time=30,
)
# Slow tier — every 5 minutes # Slow tier — every 5 minutes
_scheduler.add_job( _scheduler.add_job(update_slow_data, 'interval', minutes=5, id='slow_tier', max_instances=1, misfire_grace_time=120)
update_slow_data,
"interval",
minutes=5,
id="slow_tier",
max_instances=1,
misfire_grace_time=120,
)
# Very slow — every 15 minutes # Very slow — every 15 minutes
_scheduler.add_job( _scheduler.add_job(fetch_gdelt, 'interval', minutes=15, id='gdelt', max_instances=1, misfire_grace_time=120)
fetch_gdelt, _scheduler.add_job(update_liveuamap, 'interval', minutes=15, id='liveuamap', max_instances=1, misfire_grace_time=120)
"interval",
minutes=15,
id="gdelt",
max_instances=1,
misfire_grace_time=120,
)
_scheduler.add_job(
update_liveuamap,
"interval",
minutes=15,
id="liveuamap",
max_instances=1,
misfire_grace_time=120,
)
# CCTV pipeline refresh — hydrate DB-backed cameras immediately, then every 10 minutes. # CCTV pipeline refresh — every 10 minutes
_scheduler.add_job( # Instantiate once and reuse — avoids re-creating DB connections on every tick
run_cctv_ingest_cycle, from services.cctv_pipeline import (
"interval", TFLJamCamIngestor, LTASingaporeIngestor,
minutes=10, AustinTXIngestor, NYCDOTIngestor,
id="cctv_ingest",
max_instances=1,
misfire_grace_time=120,
next_run_time=datetime.utcnow(),
) )
_cctv_tfl = TFLJamCamIngestor()
_cctv_lta = LTASingaporeIngestor()
_cctv_atx = AustinTXIngestor()
_cctv_nyc = NYCDOTIngestor()
_now = datetime.now()
_scheduler.add_job(_cctv_tfl.ingest, 'interval', minutes=10, id='cctv_tfl', max_instances=1, misfire_grace_time=120, next_run_time=_now)
_scheduler.add_job(_cctv_lta.ingest, 'interval', minutes=10, id='cctv_lta', max_instances=1, misfire_grace_time=120, next_run_time=_now)
_scheduler.add_job(_cctv_atx.ingest, 'interval', minutes=10, id='cctv_atx', max_instances=1, misfire_grace_time=120, next_run_time=_now)
_scheduler.add_job(_cctv_nyc.ingest, 'interval', minutes=10, id='cctv_nyc', max_instances=1, misfire_grace_time=120, next_run_time=_now)
_scheduler.start() _scheduler.start()
logger.info("Scheduler started.") logger.info("Scheduler started.")
def stop_scheduler(): def stop_scheduler():
if _scheduler: if _scheduler:
_scheduler.shutdown(wait=False) _scheduler.shutdown(wait=False)
def get_latest_data(): def get_latest_data():
with _data_lock: with _data_lock:
return dict(latest_data) return dict(latest_data)
+73 -34
View File
@@ -1,9 +1,8 @@
"""Financial data fetchers — defense stocks and oil prices. """Financial data fetchers — defense stocks and oil prices.
Uses yfinance for ticker data with concurrent execution for performance. Uses yfinance batch download to minimise Yahoo Finance requests and avoid rate limiting.
""" """
import logging import logging
import concurrent.futures
import yfinance as yf import yfinance as yf
from services.fetchers._store import latest_data, _data_lock, _mark_fresh from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.retry import with_retry from services.fetchers.retry import with_retry
@@ -11,48 +10,88 @@ from services.fetchers.retry import with_retry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _fetch_single_ticker(symbol: str, period: str = "2d"): def _batch_fetch(symbols: list[str], period: str = "5d") -> dict:
"""Fetch a single yfinance ticker. Returns (symbol, data_dict) or (symbol, None).""" """Fetch multiple tickers in a single yfinance request. Returns {symbol: {price, change_percent, up}}."""
try: try:
ticker = yf.Ticker(symbol) hist = yf.download(symbols, period=period, auto_adjust=True, progress=False)
hist = ticker.history(period=period) if hist.empty:
if len(hist) >= 1: return {}
current_price = hist['Close'].iloc[-1] close = hist["Close"]
prev_close = hist['Close'].iloc[0] if len(hist) > 1 else current_price result = {}
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0 for sym in symbols:
return symbol, { try:
"price": round(float(current_price), 2), col = close[sym] if len(symbols) > 1 else close
"change_percent": round(float(change_percent), 2), col = col.dropna()
"up": bool(change_percent >= 0) if len(col) < 1:
} continue
current = float(col.iloc[-1])
prev = float(col.iloc[0]) if len(col) > 1 else current
change = ((current - prev) / prev * 100) if prev else 0
result[sym] = {
"price": round(current, 2),
"change_percent": round(change, 2),
"up": bool(change >= 0),
}
except Exception as e:
logger.warning(f"Could not parse {sym}: {e}")
return result
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch data for {symbol}: {e}") logger.warning(f"Batch fetch failed: {e}")
return symbol, None return {}
@with_retry(max_retries=1, base_delay=1) _STOCK_TICKERS = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
_OIL_MAP = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
_ALL_TICKERS = _STOCK_TICKERS + list(_OIL_MAP.values())
_MARKET_COOLDOWN_SECONDS = 1800 # fetch at most once every 30 minutes
_last_market_fetch: float = 0.0
def _fetch_all_market_data():
"""Single yfinance download for all market tickers to avoid rate limiting."""
raw = _batch_fetch(_ALL_TICKERS, period="5d")
stocks = {sym: raw[sym] for sym in _STOCK_TICKERS if sym in raw}
oil = {name: raw[sym] for name, sym in _OIL_MAP.items() if sym in raw}
return stocks, oil
@with_retry(max_retries=2, base_delay=10)
def fetch_defense_stocks(): def fetch_defense_stocks():
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"] global _last_market_fetch
import time
if time.time() - _last_market_fetch < _MARKET_COOLDOWN_SECONDS:
return
try: try:
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: stocks, oil = _fetch_all_market_data()
results = pool.map(lambda t: _fetch_single_ticker(t, "2d"), tickers) if stocks:
stocks_data = {sym: data for sym, data in results if data} _last_market_fetch = time.time()
with _data_lock: with _data_lock:
latest_data['stocks'] = stocks_data latest_data['stocks'] = stocks
_mark_fresh("stocks") if oil:
latest_data['oil'] = oil
_mark_fresh("stocks")
if oil:
_mark_fresh("oil")
logger.info(f"Markets: {len(stocks)} stocks, {len(oil)} oil tickers")
else:
logger.warning("Markets: empty result from yfinance (rate limited?)")
except Exception as e: except Exception as e:
logger.error(f"Error fetching stocks: {e}") logger.error(f"Error fetching market data: {e}")
@with_retry(max_retries=1, base_delay=1) @with_retry(max_retries=1, base_delay=10)
def fetch_oil_prices(): def fetch_oil_prices():
tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"} # Oil is now fetched together with stocks in fetch_defense_stocks to use a single request.
# This function is kept for scheduler compatibility but is a no-op if stocks already ran.
with _data_lock:
if latest_data.get('oil'):
return # Already populated by fetch_defense_stocks
try: try:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: _, oil = _fetch_all_market_data()
results = pool.map(lambda item: (_fetch_single_ticker(item[1], "5d")[1], item[0]), tickers.items()) if oil:
oil_data = {name: data for data, name in results if data} with _data_lock:
with _data_lock: latest_data['oil'] = oil
latest_data['oil'] = oil_data _mark_fresh("oil")
_mark_fresh("oil")
except Exception as e: except Exception as e:
logger.error(f"Error fetching oil: {e}") logger.error(f"Error fetching oil: {e}")