phase G: data minimisation + passwordless auth + DeepSeek-first LLM

Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.

Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.

LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.

Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-18 14:16:57 +01:00
parent 480fd311c5
commit 6e7f57c6b2
54 changed files with 5005 additions and 916 deletions

106
app/services/fx.py Normal file
View file

@ -0,0 +1,106 @@
"""FX rate fetcher with Redis-backed cache.
The universe endpoint returns prices in each ticker's *local* currency
(USD for NYSE, EUR for Paris, GBP for LSE-after-pence-normalisation,
etc.). The browser needs FX rates to convert these into the pie's base
currency for P/L computation.
Rates are expressed against a USD pivot: `fx[CCY]` = "how many CCY for
1 USD". USD itself is always 1.0. To convert X-currency value to
Y-currency: `value_y = value_x * fx[Y] / fx[X]`.
Yahoo's `=X` symbols give the right shape: `USDGBP=X` returns GBP per
USD. Rates are cached in Redis for 1 hour (FX doesn't move much for
display-purpose P/L; intraday moves are noise at the second decimal).
"""
from __future__ import annotations
import json
from typing import Iterable
import httpx
from app.logging import get_logger
from app.redis_client import get_redis
from app.services.market import fetch_yahoo
log = get_logger("fx")
_CACHE_KEY = "fx:rates:v1"
_CACHE_TTL_SECONDS = 3600 # 1 hour
# Synonyms / shorthand currencies that should resolve to a canonical
# code before lookup. "GBp" (pence) is normalised to GBP at the
# universe endpoint, but we still set up the mapping defensively.
_CANONICALISE = {
"GBP.": "GBP",
"GBX": "GBP",
"GBp": "GBP",
}
def _canonical(ccy: str) -> str:
return _CANONICALISE.get(ccy, ccy)
async def _fetch_one(client: httpx.AsyncClient, ccy: str) -> float | None:
"""Yahoo: `USD<ccy>=X` returns units of <ccy> per 1 USD."""
q = await fetch_yahoo(client, f"USD{ccy}=X", ccy, "")
if q.price is None or q.price <= 0:
return None
return float(q.price)
async def get_rates(currencies: Iterable[str]) -> dict[str, float]:
"""Return `{ccy: units-per-USD}` for every currency requested.
USD is always 1.0. Unknown / fetch-failed currencies are omitted
rather than poisoned callers must check membership before
converting (browser falls back to "no conversion" for missing
pairs, which keeps the panel readable even when FX is degraded).
Cached in Redis for 1 hour; live fetches happen only on cache miss
or when the cached set doesn't cover all needed currencies."""
wanted = {_canonical(c) for c in currencies if c}
wanted.add("USD") # pivot — always present
r = get_redis()
cached_raw = await r.get(_CACHE_KEY)
cached: dict[str, float] = {}
if cached_raw:
try:
cached = json.loads(cached_raw)
except Exception:
cached = {}
missing = wanted - set(cached.keys())
if not missing:
return {c: cached[c] for c in wanted}
# Fetch any missing rates in parallel. Keep the existing cache to
# avoid re-fetching unchanged currencies.
rates = dict(cached)
rates["USD"] = 1.0
fetch_list = [c for c in missing if c != "USD"]
if fetch_list:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
import asyncio
results = await asyncio.gather(
*(_fetch_one(client, c) for c in fetch_list),
return_exceptions=True,
)
for c, val in zip(fetch_list, results):
if isinstance(val, Exception):
log.warning("fx.fetch_failed", ccy=c, error=str(val)[:120])
continue
if val is not None:
rates[c] = val
# Persist (merged) cache.
await r.set(_CACHE_KEY, json.dumps(rates), ex=_CACHE_TTL_SECONDS)
log.info("fx.cache_refreshed", count=len(rates))
return {c: rates[c] for c in wanted if c in rates}