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>
This commit is contained in:
Giorgio Gilestro 2026-05-16 11:00:42 +01:00
parent 16e9f5f0cc
commit 8a155ef157
6 changed files with 439 additions and 12 deletions

View file

@ -9,7 +9,7 @@ import calendar as _cal
import re
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
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
@ -383,6 +383,66 @@ async def log_days(
# --- 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,