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

View file

@ -34,9 +34,6 @@ from app.models import (
Headline,
IndicatorSummary,
JobRun,
Portfolio,
PortfolioSnapshot,
Position,
Quote,
StrategicLog,
)
@ -44,7 +41,6 @@ from app.schemas import (
HealthOut,
HeadlineOut,
JobStatus,
PortfolioSummary,
QuoteOut,
StrategicLogOut,
)
@ -52,7 +48,8 @@ from app.schemas import (
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "portfolio_job", "ai_log_job", "rollup_job")
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
"indicator_summary_job", "universe_flush_job")
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
# Per-group expected freshness — bonds and intraday tape want daily data,
@ -133,6 +130,7 @@ async def indicators(
group: str,
request: Request,
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
sub = (
@ -170,12 +168,22 @@ async def indicators(
rows = [r for r in rows if r.symbol in configured]
has_anchor = any((r.changes or {}).get("anchor") is not None for r in rows)
wanted_tone = _resolve_tone_param(tone)
summary = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == group)
.where(IndicatorSummary.tone == wanted_tone)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
if summary is None:
# Fallback during rollout: any tone for this group.
summary = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == group)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
# Mark rows whose `as_of` is older than the group-specific threshold.
# Daily-tape groups (bonds, rates, equity, ...) flag stale earlier
@ -195,7 +203,8 @@ async def indicators(
request, "partials/indicators.html",
{"quotes": rows, "has_anchor": has_anchor,
"summary": summary, "notes": notes,
"stale_symbols": stale_symbols},
"stale_symbols": stale_symbols,
"tone": wanted_tone},
)
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
@ -257,19 +266,42 @@ def _log_partial_payload(row: StrategicLog | None) -> dict | None:
}
def _resolve_tone_param(tone: str | None) -> str:
"""Normalise a query-param tone to one of the two valid values.
PRO is silently mapped to INTERMEDIATE (see openrouter.PROMPT_VERSION 6)."""
if not tone:
return get_settings().CASSANDRA_TONE.upper()
upper = tone.upper().strip()
if upper in ("NOVICE", "INTERMEDIATE"):
return upper
return "INTERMEDIATE"
@router.get("/log/latest")
async def log_latest(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
):
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
select(StrategicLog)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
# Fallback during rollout: if the requested tone isn't produced yet,
# serve whatever is latest rather than 404 the panel.
if row is None:
row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html", {"log": _log_partial_payload(row)},
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone},
)
if row is None:
@ -283,22 +315,35 @@ async def log_by_date(
day: str,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
):
"""Canonical log for a given day = MAX(generated_at) within that day."""
"""Canonical log for a given day = MAX(generated_at) within that day,
filtered by tone (NOVICE | INTERMEDIATE; default from settings)."""
try:
target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
if row is None:
# Fallback: any tone for that day.
row = (await session.execute(
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html", {"log": _log_partial_payload(row)},
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
@ -380,119 +425,9 @@ async def log_days(
return templates.TemplateResponse(request, "partials/calendar.html", payload)
# --- Portfolios --------------------------------------------------------------
# 2 MiB max for CSV uploads — T212 pies don't exceed a few KB in practice.
# Keeps the abuse vector small without rejecting legitimate exports.
_MAX_CSV_BYTES = 2 * 1024 * 1024
@router.post("/portfolios/upload")
async def upload_portfolio_csv(
file: UploadFile = File(...),
portfolio_name: str | None = Form(default=None),
currency: str = Form(default="GBP"),
session: AsyncSession = Depends(get_session),
):
"""Import a Trading 212 pie-export CSV. Parses, resolves each Slice to a
T212 ticker + Yahoo symbol via InstrumentMap, and persists a new
PortfolioSnapshot + Position rows.
No user-id scoping yet that lands in phase C. Until then, all uploads
land in the single shared portfolio identified by name."""
from app.services.csv_import import CSVImportError, parse_t212_csv, persist_pie
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
if not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="File must have .csv extension")
raw = await file.read(_MAX_CSV_BYTES + 1)
if len(raw) > _MAX_CSV_BYTES:
raise HTTPException(status_code=413, detail=f"File exceeds {_MAX_CSV_BYTES} bytes")
if not raw:
raise HTTPException(status_code=400, detail="File is empty")
try:
pie = parse_t212_csv(raw)
except CSVImportError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
result = await persist_pie(
session, pie,
portfolio_name=portfolio_name,
currency=currency,
)
except Exception as e:
# Roll back; surface a clean error
await session.rollback()
raise HTTPException(status_code=500, detail=f"Persist failed: {e}")
return {
"portfolio_id": result.portfolio_id,
"snapshot_id": result.snapshot_id,
"portfolio_name": result.portfolio_name,
"is_new_portfolio": result.is_new_portfolio,
"positions": result.positions_written,
"unmapped": result.unmapped_slices,
"invested": pie.invested,
"value": pie.value,
"result": pie.result,
}
@router.get("/portfolios")
async def portfolios(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
rows: list[PortfolioSummary] = []
for p in (await session.execute(select(Portfolio))).scalars().all():
snap = (await session.execute(
select(PortfolioSnapshot)
.where(PortfolioSnapshot.portfolio_id == p.id)
.order_by(desc(PortfolioSnapshot.snapshot_at))
.limit(1)
)).scalar_one_or_none()
positions: list = []
if snap is not None:
pos = (await session.execute(
select(Position).where(Position.snapshot_id == snap.id)
.order_by(desc(
(Position.quantity * Position.current_price).label("v")
))
)).scalars().all()
positions = [
{"ticker": x.ticker, "name": x.name, "quantity": x.quantity,
"average_price": x.average_price, "current_price": x.current_price,
"ppl": x.ppl,
"ppl_pct": (
(x.current_price - x.average_price) / x.average_price * 100
if x.average_price and x.current_price else None
)}
for x in pos
]
raw = (snap.raw_json or {}) if snap else {}
inv = raw.get("investments") or {}
rows.append(PortfolioSummary(
name=p.name, currency=p.currency,
snapshot_at=snap.snapshot_at if snap else None,
total_value=snap.total_value if snap else None,
cash=snap.cash if snap else None,
invested=snap.invested if snap else None,
total_cost=inv.get("totalCost"),
unrealized_ppl=inv.get("unrealizedProfitLoss"),
realized_ppl=inv.get("realizedProfitLoss"),
positions=positions,
))
if as_ == "html":
return templates.TemplateResponse(
request, "partials/portfolio.html", {"portfolios": rows},
)
return rows
# Portfolio endpoints moved to app/routers/universe.py (Phase G). The
# server no longer persists per-user portfolio data; holdings live in
# the browser's localStorage and prices come from /api/universe.
# --- Health / ops footer -----------------------------------------------------
@ -509,13 +444,23 @@ async def aggregate_summary(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
):
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == AGGREGATE_GROUP_NAME)
.where(IndicatorSummary.tone == wanted_tone)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
if row is None:
row = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == AGGREGATE_GROUP_NAME)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
from app.services.markets import all_statuses
statuses = all_statuses()
@ -523,7 +468,7 @@ async def aggregate_summary(
if as_ == "html":
return templates.TemplateResponse(
request, "partials/dashboard_header.html",
{"summary": row, "markets": statuses},
{"summary": row, "markets": statuses, "tone": wanted_tone},
)
return {
"summary": (
@ -538,6 +483,86 @@ async def aggregate_summary(
}
# Market → headline index mapping for the sticky bottom bar. Symbols must
# be present in config/default.toml so market_job populates `quotes`.
_MARKET_INDEX = {
"NYSE": ("^GSPC", "S&P 500"),
"LSE": ("^FTSE", "FTSE 100"),
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
# config/default.toml's equity group.
"XETRA": ("^STOXX50E", "STOXX 50"),
"JPX": ("^N225", "Nikkei 225"),
"HKEX": ("^HSI", "Hang Seng"),
"SSE": ("000300.SS", "CSI 300"),
}
def _fmt_price(p: float | None) -> str:
if p is None:
return ""
if abs(p) >= 1000:
return f"{p:,.0f}"
if abs(p) >= 100:
return f"{p:,.1f}"
return f"{p:,.2f}"
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
async def markets_bar(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""The sticky bottom-bar payload: per-market open/close status with the
market's headline index price + 1d change. Refreshed by HTMX every 60s.
"""
from app.services.markets import all_statuses
statuses = all_statuses()
# Latest quote per headline-index symbol in one query.
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
.where(Quote.symbol.in_(wanted_syms))
.group_by(Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
)
)).scalars().all()
by_sym = {q.symbol: q for q in rows}
markets: list[dict] = []
for st in statuses:
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
q = by_sym.get(sym) if sym else None
idx = None
if q is not None and q.price is not None:
idx = {
"symbol": q.symbol,
"label": label,
"price_fmt": _fmt_price(q.price),
"change_1d_pct": (q.changes or {}).get("1d"),
}
markets.append({
"code": st["code"],
"label": st["label"],
"open": st["open"],
"until_iso": st["until"].isoformat(),
"until_hhmm": st["until"].strftime("%H:%M"),
"index": idx,
})
return templates.TemplateResponse(
request, "partials/markets_bar.html",
{"markets": markets},
)
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,