274 lines
8.6 KiB
Python
274 lines
8.6 KiB
Python
|
|
"""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(),
|
||
|
|
}
|