read.markets/app/routers/api.py
Giorgio Gilestro 8a155ef157 phase B (2/2): CSV upload endpoint + drag-drop UI
Completes Phase B. The full alternative-onboarding flow is now end-to-end:
drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot
+ Position rows, all without ever asking the user for broker credentials.

- persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves
  each Slice via InstrumentMap, writes Portfolio + Snapshot + Position
  rows. Unmapped slices are still persisted using their CSV values and
  surfaced in the response for the UI to warn about.
- POST /api/portfolios/upload: multipart endpoint accepting CSV file +
  optional portfolio_name + currency. 2 MiB cap. Returns import summary.
- /upload page with drag-drop dropzone, file input fallback, and inline
  result panel showing invested/value/result + unmapped-slice warnings.
- New "Import" link in the header nav.

Verified end-to-end against the real T212 export: all 13 positions land
with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies
listing the heuristic resolver picks), zero unmapped slices, totals
reconcile to the penny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:00:42 +01:00

744 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""JSON / HTMX-partial endpoints. Each route content-negotiates via `?as=html`:
bare requests return Pydantic JSON, `as=html` renders the matching partial.
The bearer-token dependency applies to all routes here.
"""
from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from collections import defaultdict
import httpx
from pydantic import BaseModel, Field
from app.auth import require_token
from app.config import get_settings
from app.db import get_session, utcnow
from app.services.openrouter import (
PROMPT_VERSION,
build_chat_system_prompt,
call_openrouter,
month_start,
)
from app.templates_env import templates
from app.models import (
AICall,
Headline,
IndicatorSummary,
JobRun,
Portfolio,
PortfolioSnapshot,
Position,
Quote,
StrategicLog,
)
from app.schemas import (
HealthOut,
HeadlineOut,
JobStatus,
PortfolioSummary,
QuoteOut,
StrategicLogOut,
)
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "portfolio_job", "ai_log_job", "rollup_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,
# macro/economy/valuation are monthly/quarterly by nature. Older than this
# many days from today → row gets a "stale" badge.
_STALE_DAYS_BY_GROUP = {
"bonds": 21,
"rates": 7,
"equity": 7,
"mag7": 7,
"commodities": 7,
"fx": 7,
"tech_ai": 7,
"financials": 7,
"bubble_watch":21,
"valuation": 60,
"macro": 60,
"economy": 90,
}
_STALE_DAYS_DEFAULT = 90
# --- Small helpers -----------------------------------------------------------
def _as_utc(d: datetime) -> datetime:
"""MariaDB returns naive datetimes — tag them UTC so arithmetic with
tz-aware utcnow() doesn't blow up."""
return d if d.tzinfo is not None else d.replace(tzinfo=timezone.utc)
def _age_seconds(now: datetime, when: datetime | None) -> float | None:
if when is None:
return None
return (_as_utc(now) - _as_utc(when)).total_seconds()
def _fmt_age(now: datetime, when: datetime | None) -> str:
secs = _age_seconds(now, when)
if secs is None:
return ""
if secs < 60:
return f"{int(secs)}s"
if secs < 3600:
return f"{int(secs // 60)}m"
if secs < 86400:
return f"{int(secs // 3600)}h"
return f"{int(secs // 86400)}d"
_MD_HEADER = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE)
_MD_BOLD = re.compile(r"\*\*([^*]+)\*\*")
def _md_to_html(text: str) -> str:
"""Tiny markdown subset for log output — headers, bold, paragraphs."""
def header_sub(m):
level = min(3, len(m.group(1))) + 1 # h2/h3/h4
return f"<h{level}>{m.group(2).strip()}</h{level}>"
out = _MD_HEADER.sub(header_sub, text)
out = _MD_BOLD.sub(r"<strong>\1</strong>", out)
# Convert blank-line-separated paragraphs to <p> blocks.
blocks = re.split(r"\n\s*\n", out.strip())
rendered: list[str] = []
for b in blocks:
if b.startswith("<h"):
rendered.append(b)
else:
rendered.append(f"<p>{b.strip().replace(chr(10), '<br>')}</p>")
return "\n".join(rendered)
# --- Indicators --------------------------------------------------------------
@router.get("/indicators/{group}")
async def indicators(
group: str,
request: Request,
as_: str | None = Query(default=None, alias="as"),
session: AsyncSession = Depends(get_session),
):
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
.where(Quote.group_name == group)
.group_by(Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote)
.join(sub,
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx))
.where(Quote.group_name == group)
.order_by(Quote.symbol)
)).scalars().all()
if as_ == "html":
from app.config import get_settings, load_groups
from app.services.market import parse_symbol
s_ = get_settings()
# Build the set of symbols currently configured for this group AND a
# notes lookup keyed by the post-parse identifier (the form stored in
# the DB).
notes: dict[str, str] = {}
configured: set[str] = set()
for sym, _lab, note in load_groups(s_.BASELINE_TOML, s_.PORTFOLIO_TOML).get(group, []):
_fn, ident = parse_symbol(sym)
notes[ident] = note
configured.add(ident)
# Drop ghost rows: symbols that used to be in this group but were
# removed from the TOML — their last quote still sits in the DB until
# rollup prunes it, but we shouldn't show them.
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)
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
# than monthly groups (economy, macro, valuation).
today = utcnow().date()
threshold = _STALE_DAYS_BY_GROUP.get(group, _STALE_DAYS_DEFAULT)
stale_symbols: set[str] = set()
for r in rows:
try:
as_of_d = datetime.strptime(r.as_of, "%Y-%m-%d").date() if r.as_of else None
except ValueError:
as_of_d = None
if as_of_d and (today - as_of_d).days > threshold:
stale_symbols.add(r.symbol)
return templates.TemplateResponse(
request, "partials/indicators.html",
{"quotes": rows, "has_anchor": has_anchor,
"summary": summary, "notes": notes,
"stale_symbols": stale_symbols},
)
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
# --- News --------------------------------------------------------------------
@router.get("/news")
async def news_list(
request: Request,
session: AsyncSession = Depends(get_session),
category: str | None = Query(None),
since_hours: float = Query(24.0, ge=0.1, le=720.0),
limit: int = Query(50, ge=1, le=500),
as_: str | None = Query(default=None, alias="as"),
):
cutoff = utcnow() - timedelta(hours=since_hours)
stmt = select(Headline).where(Headline.published_at >= cutoff)
if category:
stmt = stmt.where(Headline.category == category)
stmt = stmt.order_by(desc(Headline.published_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
if as_ == "html":
now = utcnow()
items = []
for h in rows:
when = _as_utc(h.published_at) if h.published_at else None
items.append({
"age": _fmt_age(now, h.published_at),
"source": h.source,
"title": h.title,
"url": h.url,
"iso": when.isoformat() if when else None,
"utc_short": when.strftime("%d %b %H:%M") + "Z" if when else "",
})
return templates.TemplateResponse(
request, "partials/news.html", {"headlines": items},
)
return [HeadlineOut.model_validate(r, from_attributes=True) for r in rows]
# --- Strategic log -----------------------------------------------------------
def _log_partial_payload(row: StrategicLog | None) -> dict | None:
if row is None:
return None
return {
"content_html": _md_to_html(row.content),
"generated_at": row.generated_at,
"model": row.model,
"tone": row.tone,
"analysis": row.analysis,
"prompt_version": row.prompt_version,
"cost_usd": row.cost_usd,
"prompt_tokens": row.prompt_tokens,
"completion_tokens": row.completion_tokens,
}
@router.get("/log/latest")
async def log_latest(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
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)},
)
if row is None:
raise HTTPException(status_code=404, detail="No strategic log generated yet")
return StrategicLogOut.model_validate(row, from_attributes=True)
@router.get("/log/by-date/{day}")
async def log_by_date(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Canonical log for a given day = MAX(generated_at) within that day."""
try:
target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
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)},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
return StrategicLogOut.model_validate(row, from_attributes=True)
# --- Calendar archive --------------------------------------------------------
def _month_grid(year: int, month: int) -> list[list[int | None]]:
"""6 weeks × 7 days, Monday-first; None for cells outside the month."""
weeks = _cal.Calendar(firstweekday=0).monthdayscalendar(year, month)
while len(weeks) < 6:
weeks.append([0] * 7)
return [[d or None for d in w] for w in weeks]
def _shift_month(year: int, month: int, delta: int) -> tuple[int, int]:
m = month + delta
y = year + (m - 1) // 12
m = ((m - 1) % 12) + 1
return y, m
@router.get("/log/days")
async def log_days(
request: Request,
month: str = Query(..., pattern=r"^\d{4}-\d{2}$"),
selected: str | None = Query(None),
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Return the calendar widget for a given month with the days that have
a strategic log highlighted."""
year, mnum = (int(p) for p in month.split("-"))
month_start = date(year, mnum, 1)
next_y, next_m = _shift_month(year, mnum, 1)
month_end = date(next_y, next_m, 1)
rows = (await session.execute(
select(func.distinct(func.date(StrategicLog.generated_at)))
.where(StrategicLog.generated_at >= month_start)
.where(StrategicLog.generated_at < month_end)
)).all()
# SQLAlchemy returns date or string depending on dialect; normalise to ints.
days_with_logs: set[int] = set()
for (d,) in rows:
if isinstance(d, str):
d = datetime.strptime(d, "%Y-%m-%d").date()
days_with_logs.add(d.day)
prev_y, prev_m = _shift_month(year, mnum, -1)
sel_date: date | None = None
if selected:
try:
sel_date = datetime.strptime(selected, "%Y-%m-%d").date()
except ValueError:
sel_date = None
payload = {
"year": year,
"month": mnum,
"month_name": _cal.month_name[mnum],
"grid": _month_grid(year, mnum),
"days_with_logs": days_with_logs,
"selected": sel_date,
"today": datetime.now(timezone.utc).date(),
"prev_month": f"{prev_y:04d}-{prev_m:02d}",
"next_month": f"{next_y:04d}-{next_m:02d}",
}
if as_ == "json":
return JSONResponse({
"month": month,
"days_with_logs": sorted(days_with_logs),
"prev_month": payload["prev_month"],
"next_month": payload["next_month"],
})
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
# --- Health / ops footer -----------------------------------------------------
# --- Aggregate summary + market status (dashboard header) -------------------
AGGREGATE_GROUP_NAME = "__all__"
@router.get("/summary/aggregate")
async def aggregate_summary(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
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()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/dashboard_header.html",
{"summary": row, "markets": statuses},
)
return {
"summary": (
{"content": row.content,
"generated_at": row.generated_at.isoformat(),
"model": row.model}
if row else None
),
"markets": [
{**m, "until": m["until"].isoformat()} for m in statuses
],
}
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
structured object. The default is HTML because that's how the dashboard
consumes it; CLI/curl users will pass ?as=json."""
try:
await session.execute(select(func.now()))
db_ok = True
except Exception:
db_ok = False
now = utcnow()
jobs: list[dict] = []
structured: list[JobStatus] = []
for name in JOB_NAMES:
row = (await session.execute(
select(JobRun).where(JobRun.name == name)
.order_by(desc(JobRun.started_at)).limit(1)
)).scalar_one_or_none()
if row is None:
jobs.append({"name": name, "led": "idle", "age": "",
"last_finished": None})
structured.append(JobStatus(name=name))
continue
if row.status == "success":
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
elif row.status == "skipped":
led = "warn"
elif row.status == "running":
led = "warn"
else:
led = "err"
jobs.append({
"name": name, "led": led,
"age": _fmt_age(now, row.finished_at or row.started_at),
"last_finished": row.finished_at,
})
structured.append(JobStatus(
name=name, last_started=row.started_at,
last_finished=row.finished_at, status=row.status,
error=row.error, items_written=row.items_written,
))
if as_ == "json":
return JSONResponse(
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
)
return templates.TemplateResponse(
request, "partials/ops_footer.html",
{"db_ok": db_ok, "jobs": jobs},
)
# --- Chat -------------------------------------------------------------------
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
CHAT_REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
THESIS_KEYWORDS_FALLBACK = [
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
"nato", "defence", "defense",
]
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
sub = (
select(Quote.group_name, Quote.symbol,
func.max(Quote.fetched_at).label("mx"))
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
).order_by(Quote.group_name, Quote.symbol)
)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append({
"symbol": q.symbol, "label": q.label,
"price": q.price, "currency": q.currency,
"as_of": q.as_of, "changes": q.changes,
})
return by_group
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
cutoff = utcnow() - timedelta(hours=24)
rows = (await session.execute(
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(300)
)).scalars().all()
out = []
for h in rows:
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
out.append({"source": h.source, "title": h.title})
if len(out) >= limit:
break
return out
async def _month_spend(session: AsyncSession) -> float:
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= month_start())
)).scalar()
return float(total or 0.0)
@router.post("/chat")
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral — the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
# Monthly cost cap — same one the log job respects.
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
raise HTTPException(
status_code=429,
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
)
# Trim runaway conversations: keep last 20 turns.
history = body.messages[-20:]
if not history or history[-1].role != "user":
raise HTTPException(status_code=400, detail="Last message must be user")
# Gather grounding context.
log_row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
quotes = await _latest_quotes_by_group_chat(session)
headlines = await _thesis_headlines_for_chat(session)
system_prompt = build_chat_system_prompt(
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
log_content=log_row.content if log_row else None,
log_generated_at=log_row.generated_at if log_row else None,
quotes_by_group=quotes,
headlines=headlines,
reference_line=CHAT_REFERENCE_LINE,
)
msgs = [{"role": "system", "content": system_prompt}]
for m in history:
msgs.append({"role": m.role, "content": m.content})
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_openrouter(client, msgs, model=s.OPENROUTER_MODEL)
except Exception as e:
session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
))
await session.commit()
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
return {
"role": "assistant",
"content": result.content,
"content_html": _md_to_html(result.content),
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}