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>
This commit is contained in:
commit
a10409c02b
61 changed files with 4890 additions and 0 deletions
285
app/services/market.py
Normal file
285
app/services/market.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue