"""Live supermarket & food-retail stock quotes — Yahoo Finance.""" from __future__ import annotations import json from datetime import datetime, timezone from typing import Any from urllib.parse import quote from urllib.request import Request, urlopen USER_AGENT = "Foodlinkk-MarketIntel/1.0" DATA_SOURCE = "Yahoo Finance" SOURCE_BASE = "https://finance.yahoo.com/quote/" # Beursgenoteerde supermarkt / food-retail ketens (geen FMCG zoals Unilever) LISTED_SUPERMARKET_STOCKS = [ { "symbol": "AD.AS", "name": "Ahold Delhaize", "chains": ["Albert Heijn", "Gall & Gall", "Etos", "Bol"], "country": "NL/EU", "exchange": "Euronext Amsterdam", "listed": True, "color": "#0066cc", }, { "symbol": "CAR.PA", "name": "Carrefour", "chains": ["Carrefour", "Carrefour Express"], "country": "EU", "exchange": "Euronext Paris", "listed": True, "color": "#005baa", }, { "symbol": "TSCO.L", "name": "Tesco", "chains": ["Tesco", "Tesco Express"], "country": "UK", "exchange": "London Stock Exchange", "listed": True, "color": "#0050aa", }, { "symbol": "SBRY.L", "name": "Sainsbury's", "chains": ["Sainsbury's", "Argos food"], "country": "UK", "exchange": "London Stock Exchange", "listed": True, "color": "#f06c00", }, { "symbol": "MRW.L", "name": "Morrisons", "chains": ["Morrisons"], "country": "UK", "exchange": "London Stock Exchange", "listed": True, "color": "#f5c518", }, { "symbol": "MKS.L", "name": "Marks & Spencer", "chains": ["M&S Food"], "country": "UK", "exchange": "London Stock Exchange", "listed": True, "color": "#00663d", }, { "symbol": "COLR.BR", "name": "Colruyt Group", "chains": ["Colruyt", "Bio-Planet", "OKay"], "country": "BE/EU", "exchange": "Euronext Brussels", "listed": True, "color": "#e30613", }, { "symbol": "ICA-B.ST", "name": "ICA Gruppen", "chains": ["ICA", "Maxi", "Rimi"], "country": "Nordics", "exchange": "Nasdaq Stockholm", "listed": True, "color": "#e30613", }, { "symbol": "KR", "name": "Kroger", "chains": ["Kroger", "Albertsons merger context"], "country": "USA", "exchange": "NYSE", "listed": True, "color": "#004b87", }, { "symbol": "WMT", "name": "Walmart", "chains": ["Walmart", "Sam's Club"], "country": "USA", "exchange": "NYSE", "listed": True, "color": "#0071ce", }, { "symbol": "COST", "name": "Costco", "chains": ["Costco Wholesale"], "country": "USA/Global", "exchange": "NASDAQ", "listed": True, "color": "#e31837", }, ] # NL supermarkten — niet beursgenoteerd (transparantie voor CEO) UNLISTED_NL_CHAINS = [ { "symbol": None, "name": "Jumbo", "chains": ["Jumbo", "Jumbo City"], "country": "NL", "exchange": "Familiebedrijf · niet beursgenoteerd", "listed": False, "parent": "Van Eerd familie", "color": "#ffcc00", "info_url": "https://www.jumbo.com/over-jumbo", }, { "symbol": None, "name": "Plus", "chains": ["Plus", "Plus Compact"], "country": "NL", "exchange": "Coöperatief · niet beursgenoteerd", "listed": False, "parent": "Plus Retail (coöperatie)", "color": "#008040", "info_url": "https://www.plus.nl", }, { "symbol": None, "name": "Dirk van den Broek", "chains": ["Dirk", "Dekamarkt"], "country": "NL", "exchange": "Privé · niet beursgenoteerd", "listed": False, "parent": "Schuitema / Dirk van den Broek", "color": "#e30613", "info_url": "https://www.dirk.nl", }, { "symbol": None, "name": "Lidl", "chains": ["Lidl"], "country": "NL/EU", "exchange": "Schwarz Group · privé", "listed": False, "parent": "Schwarz Gruppe (DE)", "color": "#0050aa", "info_url": "https://www.lidl.nl", }, { "symbol": None, "name": "ALDI", "chains": ["ALDI", "ALDI Nord/Süd"], "country": "NL/EU", "exchange": "Privé · niet beursgenoteerd", "listed": False, "parent": "Aldi Süd / Aldi Nord", "color": "#0066b3", "info_url": "https://www.aldi.nl", }, ] def _yahoo_url(symbol: str) -> str: return f"{SOURCE_BASE}{quote(symbol, safe='')}" def _fetch_chart(symbol: str) -> dict[str, Any]: url = ( f"https://query1.finance.yahoo.com/v8/finance/chart/{quote(symbol, safe='')}" f"?interval=1d&range=1mo&includePrePost=false" ) req = Request(url, headers={"User-Agent": USER_AGENT}) with urlopen(req, timeout=14) as resp: payload = json.loads(resp.read().decode()) result = (payload.get("chart") or {}).get("result") or [] if not result: return {} meta = result[0].get("meta") or {} closes = (result[0].get("indicators") or {}).get("quote") or [{}] close_series = closes[0].get("close") or [] valid = [c for c in close_series if c is not None] sparkline = valid[-14:] if len(valid) >= 14 else valid prev = valid[-2] if len(valid) >= 2 else None last = valid[-1] if valid else meta.get("regularMarketPrice") change_pct = meta.get("regularMarketChangePercent") if change_pct is None and prev and last and prev: change_pct = ((last - prev) / prev) * 100 return { "price": meta.get("regularMarketPrice") or last, "currency": meta.get("currency") or "EUR", "change_pct": round(float(change_pct or 0), 2), "change_abs": meta.get("regularMarketChange"), "sparkline": [round(float(v), 2) for v in sparkline], "market_state": meta.get("marketState") or "CLOSED", "exchange_name": meta.get("exchangeName") or meta.get("fullExchangeName"), "quote_time": meta.get("regularMarketTime"), } def fetch_supermarket_quotes() -> list[dict[str, Any]]: now = datetime.now(timezone.utc).isoformat() items: list[dict[str, Any]] = [] for stock in LISTED_SUPERMARKET_STOCKS: row = dict(stock) sym = stock["symbol"] row["source"] = DATA_SOURCE row["source_url"] = _yahoo_url(sym) row["chart_api"] = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}" row["fetched_at"] = now try: chart = _fetch_chart(sym) row.update(chart) row["trend"] = "up" if (row.get("change_pct") or 0) >= 0 else "down" row["live"] = row.get("price") is not None except Exception as exc: # noqa: BLE001 row["error"] = str(exc)[:100] row["price"] = None row["change_pct"] = 0 row["sparkline"] = [] row["trend"] = "flat" row["live"] = False items.append(row) for chain in UNLISTED_NL_CHAINS: row = dict(chain) row["source"] = "Foodlinkk Intel" row["source_url"] = chain.get("info_url") row["fetched_at"] = now row["live"] = False row["price"] = None row["change_pct"] = None row["note"] = "Niet beursgenoteerd — geen live koers beschikbaar" items.append(row) return items def fetch_retail_quotes() -> list[dict[str, Any]]: """Back-compat — alleen beursgenoteerde supermarkt-aandelen.""" return [q for q in fetch_supermarket_quotes() if q.get("listed")] def market_summary(quotes: list[dict[str, Any]] | None = None) -> dict[str, Any]: quotes = quotes or fetch_retail_quotes() valid = [q for q in quotes if q.get("price") is not None] avg_change = sum(float(q.get("change_pct") or 0) for q in valid) / len(valid) if valid else 0 best = max(valid, key=lambda q: float(q.get("change_pct") or 0), default=None) worst = min(valid, key=lambda q: float(q.get("change_pct") or 0), default=None) return { "avg_change_pct": round(avg_change, 2), "best_performer": best, "worst_performer": worst, "quote_count": len(valid), "listed_count": len(LISTED_SUPERMARKET_STOCKS), "unlisted_nl_count": len(UNLISTED_NL_CHAINS), "data_source": DATA_SOURCE, "updated_at": datetime.now(timezone.utc).isoformat(), }