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:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
"""Authentication routes: /login, /signup, /logout.
|
||||
"""Authentication routes: /login, /verify, /verify/resend, /logout.
|
||||
|
||||
These do NOT depend on require_auth (they're how you become authenticated).
|
||||
The router is included separately in app/main.py without a router-level
|
||||
auth dependency.
|
||||
Cassandra is passwordless. Single auth flow:
|
||||
|
||||
GET /login → enter email
|
||||
POST /login → get_or_create_user → issue OTP → send → 303 /verify
|
||||
GET /verify → enter 6-digit code (email shown from pending cookie)
|
||||
POST /verify → validate → set session → 303 /
|
||||
POST /verify/resend → reissue OTP (rate-limited)
|
||||
|
||||
Signup and login are intentionally the same path — typing your email is
|
||||
sign-in if you've been here before, sign-up otherwise. No UI signal
|
||||
distinguishes the two, which also masks user-enumeration.
|
||||
|
||||
The /signup endpoints from the previous auth scheme are gone. Anything
|
||||
that linked to /signup should now link to /login.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -12,13 +23,26 @@ from fastapi import APIRouter, Depends, Form, Request
|
|||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session
|
||||
from app.auth import (
|
||||
PENDING_COOKIE_NAME,
|
||||
PENDING_TTL_SECONDS,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_TTL_SECONDS,
|
||||
sign_pending,
|
||||
sign_session,
|
||||
verify_pending,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.services.auth_service import AuthError, authenticate, create_user
|
||||
from app.db import get_session, utcnow
|
||||
from app.logging import get_logger
|
||||
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
||||
from app.services import otp_service
|
||||
from app.services.email_service import EmailSendError, send_otp
|
||||
from app.templates_env import templates
|
||||
|
||||
|
||||
log = get_logger("auth_router")
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
|
|
@ -26,7 +50,6 @@ def _safe_next(next_value: str | None) -> str:
|
|||
"""Only allow same-origin relative paths to prevent open-redirect."""
|
||||
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
|
||||
return "/"
|
||||
# Block any embedded scheme or host.
|
||||
if urlparse(next_value).netloc:
|
||||
return "/"
|
||||
return next_value
|
||||
|
|
@ -39,19 +62,49 @@ def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
|||
max_age=SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
# `secure=True` requires HTTPS; the operator should enable this in
|
||||
# production via a reverse proxy. Off for local-dev convenience.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _set_pending_cookie(response: RedirectResponse, email: str, user_id: int) -> None:
|
||||
response.set_cookie(
|
||||
key=PENDING_COOKIE_NAME,
|
||||
value=sign_pending(email, user_id),
|
||||
max_age=PENDING_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _clear_pending_cookie(response) -> None:
|
||||
response.delete_cookie(PENDING_COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
|
||||
"""Generate a code, persist its hash, send the email. Returns True on
|
||||
success. Returns False (and logs) if SMTP submission fails — the OTP
|
||||
row is still created so the user can hit /verify/resend."""
|
||||
code = await otp_service.issue(session, email, purpose="auth")
|
||||
try:
|
||||
await send_otp(email, code, otp_service.OTP_TTL_MINUTES)
|
||||
return True
|
||||
except EmailSendError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login (email entry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str | None = None, error: str | None = None):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next), "error": error,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
{"next_path": _safe_next(next), "error": error},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -59,73 +112,124 @@ async def login_page(request: Request, next: str | None = None, error: str | Non
|
|||
async def login_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str | None = Form(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
try:
|
||||
user = await authenticate(session, email, password)
|
||||
user = await get_or_create_user(
|
||||
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
|
||||
)
|
||||
except AuthError as e:
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next), "error": str(e),
|
||||
"email": email,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
{"next_path": _safe_next(next), "error": str(e), "email": email},
|
||||
status_code=400,
|
||||
)
|
||||
target = _safe_next(next)
|
||||
resp = RedirectResponse(url=target, status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
|
||||
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
||||
# last 60s we just reuse the existing one (silently) to avoid
|
||||
# spamming the user's inbox on a refreshed form submit.
|
||||
allowed, _ = await otp_service.can_request_new(session, user.email)
|
||||
if allowed:
|
||||
await _issue_and_send_otp(session, user.email)
|
||||
|
||||
resp = RedirectResponse(url="/verify", status_code=303)
|
||||
_set_pending_cookie(resp, user.email, user.id)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/signup", response_class=HTMLResponse)
|
||||
async def signup_page(request: Request, error: str | None = None):
|
||||
s = get_settings()
|
||||
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.",
|
||||
"signup_enabled": False},
|
||||
status_code=403,
|
||||
)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify (code entry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/verify", response_class=HTMLResponse)
|
||||
async def verify_page(request: Request, error: str | None = None, sent: str | None = None):
|
||||
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
||||
pending = verify_pending(cookie) if cookie else None
|
||||
if pending is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
request, "signup.html",
|
||||
{"error": error},
|
||||
request, "verify.html",
|
||||
{"email": pending["email"], "error": error, "sent": sent,
|
||||
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
|
||||
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup_submit(
|
||||
@router.post("/verify")
|
||||
async def verify_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
code: str = Form(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
||||
pending = verify_pending(cookie) if cookie else None
|
||||
if pending is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
email = pending["email"]
|
||||
try:
|
||||
user = await create_user(session, email, password)
|
||||
except AuthError as e:
|
||||
await otp_service.verify(session, email, code)
|
||||
except otp_service.OTPError as e:
|
||||
return templates.TemplateResponse(
|
||||
request, "signup.html",
|
||||
{"error": str(e), "email": email},
|
||||
request, "verify.html",
|
||||
{"email": email, "error": str(e),
|
||||
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
|
||||
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
user = await get_user(session, pending["uid"])
|
||||
if user is None:
|
||||
# User row vanished between cookie issue and verify. Restart flow.
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
user.last_login_at = utcnow()
|
||||
await session.commit()
|
||||
log.info("user.login", user_id=user.id, email=email)
|
||||
|
||||
resp = RedirectResponse(url="/", status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
_clear_pending_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/verify/resend")
|
||||
async def verify_resend(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
||||
pending = verify_pending(cookie) if cookie else None
|
||||
if pending is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
email = pending["email"]
|
||||
allowed, wait = await otp_service.can_request_new(session, email)
|
||||
if not allowed:
|
||||
return RedirectResponse(
|
||||
url=f"/verify?error=Please+wait+{wait}s+before+requesting+another+code",
|
||||
status_code=303,
|
||||
)
|
||||
ok = await _issue_and_send_otp(session, email)
|
||||
msg = "A new code has been sent" if ok else "Could not send email — try again shortly"
|
||||
return RedirectResponse(url=f"/verify?sent={msg}", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
resp = RedirectResponse(url="/login", status_code=303)
|
||||
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
_clear_pending_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout_get(request: Request):
|
||||
# Convenience for users who click a logout link rather than POSTing.
|
||||
return await logout(request)
|
||||
|
|
|
|||
351
app/routers/universe.py
Normal file
351
app/routers/universe.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
"""Phase G endpoints — the data-minimised path that replaces per-user
|
||||
portfolio persistence.
|
||||
|
||||
Four routes:
|
||||
|
||||
- GET /api/universe Full ticker universe + prices.
|
||||
Identical payload for every
|
||||
authenticated user — request
|
||||
bodies don't leak which
|
||||
tickers belong to which user.
|
||||
- GET /api/universe/sparkline/{ticker} Lazy per-ticker sparkline,
|
||||
fetched on hover from the
|
||||
browser.
|
||||
- POST /api/portfolio/parse CSV → parsed pie back to
|
||||
browser localStorage. Seeds
|
||||
ticker_universe (no user FK).
|
||||
No DB writes for positions.
|
||||
- POST /api/analyze Ephemeral AI commentary.
|
||||
Pie passed in via JSON body,
|
||||
held in memory for one LLM
|
||||
call, discarded on response.
|
||||
|
||||
All routes require authentication (session cookie OR bearer token). The
|
||||
old endpoints in `app/routers/api.py` (`/api/portfolios/upload`,
|
||||
`/api/portfolio/{name}/summary`) remain live until step 10 of the Phase G
|
||||
plan, when they're removed alongside the table drops.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_session, utcnow
|
||||
from app.logging import get_logger
|
||||
from app.models import Quote, QuoteDaily
|
||||
from app.services import fx, portfolio_analysis, ticker_universe
|
||||
from app.services.csv_import import CSVImportError, parse_t212_csv
|
||||
from app.services.instrument_map import resolve_slice
|
||||
from app.services.market import fetch as market_fetch
|
||||
|
||||
|
||||
log = get_logger("universe_router")
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
# Hard caps on inbound payload sizes. Anything bigger is rejected with 4xx
|
||||
# rather than tying up an LLM call or a CSV parser.
|
||||
MAX_CSV_BYTES = 1_048_576 # 1 MB
|
||||
MAX_ANALYZE_JSON_BYTES = 256 * 1024 # 256 KB
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/universe — full ticker universe with prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/universe")
|
||||
async def get_universe(session: AsyncSession = Depends(get_session)) -> JSONResponse:
|
||||
"""Return every ticker tracked by Cassandra, with its latest quote.
|
||||
|
||||
The response is intentionally the *whole* universe — never filtered
|
||||
per user — so the access pattern (request body, return body) carries
|
||||
no information about which tickers belong to which user. Browser
|
||||
filters down to its own holdings client-side.
|
||||
|
||||
Cache-Control: 60s — the browser refreshes once a minute, matching
|
||||
market_job's hourly write cadence with slack."""
|
||||
tickers = await ticker_universe.get_all_tickers(session)
|
||||
out: dict[str, dict] = {}
|
||||
|
||||
if tickers:
|
||||
# Latest quote per ticker within the last 24h. Older = considered
|
||||
# broken feed; we drop it rather than serve stale data.
|
||||
cutoff = _utcnow() - timedelta(hours=24)
|
||||
subq = (
|
||||
select(Quote.symbol, func.max(Quote.fetched_at).label("max_fetched"))
|
||||
.where(Quote.symbol.in_(tickers))
|
||||
.where(Quote.fetched_at >= cutoff)
|
||||
.group_by(Quote.symbol)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Quote)
|
||||
.join(
|
||||
subq,
|
||||
and_(
|
||||
Quote.symbol == subq.c.symbol,
|
||||
Quote.fetched_at == subq.c.max_fetched,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
for q in rows:
|
||||
if q.price is None:
|
||||
continue
|
||||
price = q.price
|
||||
currency = q.currency
|
||||
# LSE tickers come back from Yahoo in pence (GBp / GBX) but
|
||||
# T212 CSV invested-value is reported in pounds. Normalise to
|
||||
# pounds here so the browser never has to know about the
|
||||
# pence quirk. Daily change percentages are unit-independent.
|
||||
if currency in ("GBp", "GBX"):
|
||||
price = price / 100.0
|
||||
currency = "GBP"
|
||||
out[q.symbol] = {
|
||||
"p": price,
|
||||
"c": currency,
|
||||
"d": q.changes or {},
|
||||
}
|
||||
|
||||
# FX rates for every currency present, against a USD pivot. Browser
|
||||
# uses these to convert each position into the pie's base currency
|
||||
# before computing P/L. Same payload for every user.
|
||||
needed_ccy = {q.get("c") for q in out.values() if q.get("c")}
|
||||
# Always include the common bases so the browser has them even if
|
||||
# no current position is denominated in them (e.g. avg cost in GBP
|
||||
# but no LSE holding right now).
|
||||
needed_ccy.update({"USD", "EUR", "GBP"})
|
||||
try:
|
||||
fx_rates = await fx.get_rates(needed_ccy)
|
||||
except Exception as e:
|
||||
log.warning("universe.fx_failed", error=str(e)[:200])
|
||||
fx_rates = {"USD": 1.0}
|
||||
|
||||
body = {
|
||||
"as_of": _utcnow().isoformat(),
|
||||
"tickers": out,
|
||||
"fx": fx_rates,
|
||||
}
|
||||
return JSONResponse(
|
||||
body,
|
||||
headers={
|
||||
"Cache-Control": "max-age=60",
|
||||
"Vary": "Accept-Encoding",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/universe/sparkline/{ticker} — lazy per-ticker history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/universe/sparkline/{ticker}")
|
||||
async def get_sparkline(
|
||||
ticker: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> JSONResponse:
|
||||
"""Daily closes for the last ~60 days. Browser fetches on hover, so
|
||||
we cache aggressively. 404 if the symbol has no daily rollup yet."""
|
||||
ticker = ticker.strip().upper()[:32]
|
||||
if not ticker:
|
||||
raise HTTPException(status_code=400, detail="ticker required")
|
||||
|
||||
rows = (await session.execute(
|
||||
select(QuoteDaily.date, QuoteDaily.close)
|
||||
.where(QuoteDaily.symbol == ticker)
|
||||
.where(QuoteDaily.close.is_not(None))
|
||||
.order_by(QuoteDaily.date.desc())
|
||||
.limit(60)
|
||||
)).all()
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail=f"no sparkline data for {ticker}")
|
||||
|
||||
series = [{"d": r.date.isoformat(), "c": r.close} for r in reversed(rows)]
|
||||
return JSONResponse(
|
||||
{"ticker": ticker, "series": series},
|
||||
headers={"Cache-Control": "max-age=300"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/portfolio/parse — CSV → parsed pie for browser localStorage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/portfolio/parse")
|
||||
async def parse_portfolio(
|
||||
file: UploadFile = File(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Parse a T212 pie-export CSV. Returns the structured pie to the
|
||||
browser to be stashed in localStorage. **Does NOT persist holdings.**
|
||||
|
||||
Side effects on the server:
|
||||
- Resolved Yahoo tickers are buffered into ticker_universe (no user
|
||||
FK, timing-leak mitigation via 5-min batch flush in scheduler).
|
||||
- last_referenced_at is bumped on any ticker already in the universe.
|
||||
"""
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_CSV_BYTES:
|
||||
raise HTTPException(status_code=413, detail="CSV too large (1 MB max)")
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="empty CSV")
|
||||
|
||||
try:
|
||||
pie = parse_t212_csv(raw)
|
||||
except CSVImportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
positions_out: list[dict] = []
|
||||
yahoo_tickers: list[str] = []
|
||||
unmapped: list[str] = []
|
||||
|
||||
for p in pie.positions:
|
||||
resolved = await resolve_slice(session, p.slice)
|
||||
if resolved is None or not resolved.yahoo_ticker:
|
||||
unmapped.append(p.slice or p.name or "?")
|
||||
continue
|
||||
positions_out.append({
|
||||
"yahoo_ticker": resolved.yahoo_ticker,
|
||||
"t212_slice": p.slice,
|
||||
"name": resolved.name or p.name,
|
||||
"qty": p.quantity,
|
||||
"avg_cost": p.average_price, # @property — no call parens
|
||||
"currency": resolved.currency,
|
||||
})
|
||||
yahoo_tickers.append(resolved.yahoo_ticker)
|
||||
|
||||
# Synchronous upsert: bypass the Redis buffer so the dashboard has
|
||||
# live prices immediately. The buffer + flush machinery remains for
|
||||
# multi-user timing-mitigation when we hit >=10 concurrent users.
|
||||
upserted = await ticker_universe.upsert_tickers(session, yahoo_tickers)
|
||||
# Also drop into the Redis buffer so flush_buffer's existing tests +
|
||||
# ledger remain coherent if/when we re-enable buffered-only mode.
|
||||
buffered = await ticker_universe.buffer_tickers(yahoo_tickers)
|
||||
|
||||
# Inline price fetch for the resolved tickers, so /api/universe has
|
||||
# something to return on the very first dashboard load after upload.
|
||||
# Bounded concurrency to keep Yahoo happy.
|
||||
fetched_ok = 0
|
||||
if yahoo_tickers:
|
||||
anchor = get_settings().CASSANDRA_ANCHOR_DATE or None
|
||||
now = utcnow()
|
||||
sem = asyncio.Semaphore(16)
|
||||
|
||||
async def _fetch_one(client, sym):
|
||||
async with sem:
|
||||
return await market_fetch(client, sym, sym, "", anchor)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client:
|
||||
quotes = await asyncio.gather(
|
||||
*(_fetch_one(client, t) for t in yahoo_tickers),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for sym, q in zip(yahoo_tickers, quotes):
|
||||
if isinstance(q, Exception):
|
||||
log.warning("portfolio.parse.fetch_failed", symbol=sym, error=str(q)[:120])
|
||||
continue
|
||||
session.add(Quote(
|
||||
symbol=q.symbol, source=q.source, label=q.label,
|
||||
group_name="universe", price=q.price, currency=q.currency,
|
||||
as_of=q.as_of, changes=q.changes or None,
|
||||
error=(q.error[:250] if q.error else None),
|
||||
fetched_at=now,
|
||||
))
|
||||
if q.price is not None:
|
||||
fetched_ok += 1
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
log.error("portfolio.parse.fetch_block_failed", error=str(e)[:200])
|
||||
|
||||
log.info(
|
||||
"portfolio.parse",
|
||||
positions=len(positions_out),
|
||||
unmapped=len(unmapped),
|
||||
upserted=upserted,
|
||||
buffered=buffered,
|
||||
priced=fetched_ok,
|
||||
)
|
||||
|
||||
warnings = []
|
||||
if unmapped:
|
||||
warnings.append(
|
||||
f"{len(unmapped)} position(s) could not be resolved to Yahoo tickers: "
|
||||
+ ", ".join(unmapped[:10])
|
||||
+ (" ..." if len(unmapped) > 10 else "")
|
||||
)
|
||||
|
||||
return {
|
||||
"pie_name": pie.name,
|
||||
"base_currency": "GBP",
|
||||
"positions": positions_out,
|
||||
"totals": {
|
||||
"invested": pie.invested,
|
||||
"value": pie.value,
|
||||
"result": pie.result,
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/analyze — ephemeral AI commentary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_portfolio(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Generate AI commentary for the supplied pie. The pie is held in
|
||||
memory only for the duration of the LLM call; nothing about holdings
|
||||
is persisted. The ai_calls ledger row records tokens + cost, never
|
||||
holdings."""
|
||||
# Read JSON body manually so we can enforce a hard size cap. FastAPI's
|
||||
# default body limit is generous; we want tighter control here.
|
||||
body = await request.body()
|
||||
if len(body) > MAX_ANALYZE_JSON_BYTES:
|
||||
raise HTTPException(status_code=413, detail="payload too large")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="malformed JSON body")
|
||||
|
||||
try:
|
||||
req = portfolio_analysis.parse_request(payload)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
result = await portfolio_analysis.analyse(session, req)
|
||||
except RuntimeError as e:
|
||||
log.error("analyze.llm_failed", error=str(e)[:200])
|
||||
raise HTTPException(status_code=502, detail="analysis failed — try again")
|
||||
|
||||
return {
|
||||
"content": result.content,
|
||||
"model": result.model,
|
||||
"generated_at": result.generated_at.isoformat(),
|
||||
"prompt_tokens": result.prompt_tokens,
|
||||
"completion_tokens": result.completion_tokens,
|
||||
"cost_usd": result.cost_usd,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue