read.markets/app/services/market.py
Giorgio Gilestro a10409c02b initial commit — cassandra v0.1
Containerised macro-strategy dashboard: 4-panel web UI (indicators,
portfolio, flash news, AI strategic log), MariaDB store, hourly
ingestion jobs, OpenRouter-backed AI analysis.

Ports the four prototype scripts in the parent dir (market_pulse,
flash_news, trading212, strategic_log) into async services backed by a
persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs
as a separate compose service for crash-safety and easier restarts.

Portfolio composition + position names come live from Trading 212;
news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/
PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and
stored on each log row so historical entries show what produced them.

Default model is deepseek/deepseek-v4-flash (overridable via env).
Light/dark theme toggle, sans-serif for prose surfaces, monospace for
data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto-
disabled on consecutive failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:56:10 +01:00

285 lines
9.3 KiB
Python

"""Market data fetchers — Yahoo Finance (no auth) and FRED (key required).
Ported from /home/gg/ownCloud/Family/Finances/Wealth/market_pulse.py.
Logic preserved verbatim where possible; HTTP switched to httpx.AsyncClient.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable
import httpx
from app.config import get_settings
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
FRED_API = "https://api.stlouisfed.org/fred/series/observations"
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
# --- In-flight data shape (services return these; jobs persist to DB) --------
@dataclass
class Quote:
symbol: str
source: str
label: str
note: str
price: float | None
currency: str | None
as_of: str | None
changes: dict[str, float | None] = field(default_factory=dict)
price_base: float | None = None
currency_base: str | None = None
anchor_date: str | None = None
error: str | None = None
def _pct(old: float | None, new: float | None) -> float | None:
if old is None or new is None or old == 0:
return None
return (new - old) / old * 100.0
def _parse_date(s: str) -> datetime:
return datetime.strptime(s, "%Y-%m-%d")
def _yahoo_range_covering(anchor: str | None) -> str:
if not anchor:
return "1y"
days = (datetime.now(timezone.utc).date() - _parse_date(anchor).date()).days
if days <= 360:
return "1y"
if days <= 1800:
return "5y"
if days <= 3600:
return "10y"
return "max"
# --- Fetchers -----------------------------------------------------------------
async def fetch_yahoo(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
"""Latest close + %1d / %1m / %1y / (optional) %anchor from Yahoo's chart endpoint."""
try:
r = await client.get(
YAHOO_CHART.format(symbol=symbol),
params={"interval": "1d", "range": _yahoo_range_covering(anchor),
"includePrePost": "false"},
headers=UA,
timeout=15,
)
r.raise_for_status()
result = r.json()["chart"]["result"]
if not result:
raise ValueError("empty result")
res = result[0]
meta = res["meta"]
price = meta.get("regularMarketPrice")
prev_session = meta.get("previousClose")
timestamps = res.get("timestamp") or []
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
if not series:
raise ValueError("no closes in series")
last_ts = series[-1][0]
if prev_session is None and len(series) >= 2:
prev_session = series[-2][1]
chg_1m = _pct(series[-22][1], price) if len(series) >= 22 else None
chg_1y = _pct(series[0][1], price) if len(series) >= 2 else None
changes: dict[str, float | None] = {
"1d": _pct(prev_session, price),
"1m": chg_1m,
"1y": chg_1y,
}
anchor_used: str | None = None
if anchor:
anchor_ts = int(_parse_date(anchor).replace(tzinfo=timezone.utc).timestamp())
anchor_close = next((c for t, c in series if t >= anchor_ts), None)
anchor_actual_ts = next((t for t, c in series if t >= anchor_ts), None)
changes["anchor"] = _pct(anchor_close, price)
if anchor_actual_ts:
anchor_used = datetime.fromtimestamp(
anchor_actual_ts, timezone.utc
).strftime("%Y-%m-%d")
return Quote(
symbol=symbol,
source="yahoo",
label=label,
note=note,
price=price,
currency=meta.get("currency"),
as_of=datetime.fromtimestamp(last_ts, timezone.utc).strftime("%Y-%m-%d"),
changes=changes,
anchor_date=anchor_used,
)
except Exception as e:
return Quote(symbol, "yahoo", label, note, None, None, None, error=str(e))
async def fetch_fred(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
"""Latest value + 1d/1m/1y deltas from a FRED series. Requires FRED_API_KEY."""
api_key = get_settings().FRED_API_KEY
if not api_key:
return Quote(symbol, "fred", label, note, None, None, None,
error="FRED_API_KEY not set")
try:
r = await client.get(
FRED_API,
params={
"series_id": symbol,
"api_key": api_key,
"file_type": "json",
"sort_order": "desc",
"limit": 5000 if anchor else 800,
},
headers=UA,
timeout=20,
)
r.raise_for_status()
obs = r.json().get("observations", [])
data = [
(o["date"], float(o["value"]))
for o in obs if o.get("value") not in (".", "", None)
]
if not data:
raise ValueError("no observations")
last_date, last_val = data[0]
# CPI levels reported as YoY%.
if symbol in ("CPIAUCSL", "CPILFESL") and len(data) >= 13:
yoy = _pct(data[12][1], last_val)
changes: dict[str, float | None] = {"1d": None, "1m": None, "1y": None}
anchor_used: str | None = None
if anchor:
anchor_dt = _parse_date(anchor)
anchor_idx = next(
(i for i, (d, _) in enumerate(data) if _parse_date(d) <= anchor_dt),
None,
)
if anchor_idx is not None and anchor_idx + 12 < len(data):
yoy_then = _pct(data[anchor_idx + 12][1], data[anchor_idx][1])
changes["anchor"] = (yoy or 0) - (yoy_then or 0)
anchor_used = anchor
return Quote(symbol, "fred", label, note, yoy, "%", last_date,
changes=changes, anchor_date=anchor_used)
last_dt = _parse_date(last_date)
def find_back(min_days: int) -> float | None:
for d, v in data[1:]:
if (last_dt - _parse_date(d)).days >= min_days:
return v
return None
prev_val = data[1][1] if len(data) >= 2 else None
changes = {
"1d": _pct(prev_val, last_val),
"1m": _pct(find_back(28), last_val),
"1y": _pct(find_back(360), last_val),
}
anchor_used = None
if anchor:
anchor_dt = _parse_date(anchor)
anchor_obs = next(
((d, v) for d, v in data if _parse_date(d) <= anchor_dt), None
)
if anchor_obs:
changes["anchor"] = _pct(anchor_obs[1], last_val)
anchor_used = anchor_obs[0]
return Quote(
symbol=symbol, source="fred", label=label, note=note,
price=last_val, currency=None, as_of=last_date,
changes=changes, anchor_date=anchor_used,
)
except Exception as e:
return Quote(symbol, "fred", label, note, None, None, None, error=str(e))
# --- Source registry ----------------------------------------------------------
FetcherFn = Callable[..., "Quote"]
SOURCES: dict[str, FetcherFn] = {"yahoo": fetch_yahoo, "FRED": fetch_fred}
def parse_symbol(symbol: str) -> tuple[FetcherFn, str]:
if ":" in symbol:
prefix, _, ident = symbol.partition(":")
if prefix in SOURCES:
return SOURCES[prefix], ident
return SOURCES["yahoo"], symbol
async def fetch(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
fn, ident = parse_symbol(symbol)
return await fn(client, ident, label, note, anchor)
# --- Currency normalisation ---------------------------------------------------
async def _get_fx_rate(
client: httpx.AsyncClient,
from_ccy: str,
to_ccy: str,
cache: dict[tuple[str, str], float | None],
) -> float | None:
if from_ccy == to_ccy:
return 1.0
if from_ccy == "GBp": # LSE pence
gbp = await _get_fx_rate(client, "GBP", to_ccy, cache)
return None if gbp is None else 0.01 * gbp
key = (from_ccy, to_ccy)
if key in cache:
return cache[key]
pair = f"{from_ccy}{to_ccy}=X"
try:
r = await client.get(
YAHOO_CHART.format(symbol=pair),
params={"interval": "1d", "range": "5d"},
headers=UA, timeout=10,
)
r.raise_for_status()
rate = r.json()["chart"]["result"][0]["meta"].get("regularMarketPrice")
cache[key] = rate
return rate
except Exception:
cache[key] = None
return None
async def normalise_to_base(
client: httpx.AsyncClient, quotes: list[Quote], base: str
) -> None:
cache: dict[tuple[str, str], float | None] = {}
base = base.upper()
for q in quotes:
if q.price is None or not q.currency:
continue
rate = await _get_fx_rate(client, q.currency, base, cache)
if rate is None:
continue
q.price_base = q.price * rate
q.currency_base = base