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:
parent
16e9f5f0cc
commit
8a155ef157
6 changed files with 439 additions and 12 deletions
|
|
@ -1,18 +1,19 @@
|
|||
"""Defensive parser for Trading 212 pie-export CSVs.
|
||||
"""Defensive parser for Trading 212 pie-export CSVs + writer that persists
|
||||
the parsed pie into PortfolioSnapshot/Position rows.
|
||||
|
||||
T212 has changed column order between exports historically; matching on header
|
||||
NAME rather than column index makes this robust. We also explicitly skip the
|
||||
'Total' aggregate row (it has slice='Total' and quantity='-').
|
||||
|
||||
Pure function — no DB, no HTTP. Persisting into PortfolioSnapshot/Position is
|
||||
done by the upload endpoint after mapping each Slice to a Yahoo ticker via the
|
||||
InstrumentMap service.
|
||||
The parser is pure: no DB, no HTTP, no I/O. The writer (`persist_pie`)
|
||||
takes a ParsedPie and resolves each position's Slice via InstrumentMap
|
||||
to find its Yahoo ticker + canonical name before persisting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class CSVImportError(ValueError):
|
||||
|
|
@ -197,3 +198,98 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
|
|||
value=total.current_value if total else None,
|
||||
result=total.result if total else None,
|
||||
)
|
||||
|
||||
|
||||
# --- Persist parsed pie into portfolio/snapshot/positions -------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistResult:
|
||||
portfolio_id: int
|
||||
snapshot_id: int
|
||||
positions_written: int
|
||||
unmapped_slices: list[str] # slices we couldn't resolve to a Yahoo ticker
|
||||
portfolio_name: str
|
||||
is_new_portfolio: bool
|
||||
|
||||
|
||||
async def persist_pie(
|
||||
session: "AsyncSession",
|
||||
pie: ParsedPie,
|
||||
*,
|
||||
portfolio_name: str | None = None,
|
||||
source: str = "t212-csv",
|
||||
currency: str = "GBP",
|
||||
) -> PersistResult:
|
||||
"""Write a ParsedPie into Portfolio/PortfolioSnapshot/Position.
|
||||
|
||||
- Portfolio is created on first sight of a given name; subsequent uploads
|
||||
stack as new snapshots under the same portfolio.
|
||||
- Each position's Slice is resolved to a T212 ticker + name via the
|
||||
InstrumentMap. Unmapped slices still get stored using their raw CSV
|
||||
values; we collect them in `unmapped_slices` for the UI to surface.
|
||||
"""
|
||||
# Late imports keep this module dependency-light for unit tests.
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import utcnow
|
||||
from app.models import Portfolio, PortfolioSnapshot, Position
|
||||
from app.services.instrument_map import resolve_slice
|
||||
|
||||
name = portfolio_name or pie.name or "Imported pie"
|
||||
name = name.strip()[:64]
|
||||
|
||||
portfolio = (await session.execute(
|
||||
select(Portfolio).where(Portfolio.name == name)
|
||||
)).scalar_one_or_none()
|
||||
is_new = portfolio is None
|
||||
if portfolio is None:
|
||||
portfolio = Portfolio(name=name, source=source, currency=currency)
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
|
||||
snap = PortfolioSnapshot(
|
||||
portfolio_id=portfolio.id,
|
||||
snapshot_at=utcnow(),
|
||||
total_value=pie.value,
|
||||
cash=None,
|
||||
invested=pie.invested,
|
||||
raw_json={
|
||||
"source": source,
|
||||
"pie_name": pie.name,
|
||||
"result": pie.result,
|
||||
},
|
||||
)
|
||||
session.add(snap)
|
||||
await session.flush()
|
||||
|
||||
unmapped: list[str] = []
|
||||
for p in pie.positions:
|
||||
resolved = await resolve_slice(session, p.slice)
|
||||
if resolved and resolved.t212_ticker:
|
||||
ticker = resolved.t212_ticker
|
||||
position_name = resolved.name or p.name
|
||||
else:
|
||||
ticker = p.slice
|
||||
position_name = p.name
|
||||
unmapped.append(p.slice)
|
||||
|
||||
session.add(Position(
|
||||
snapshot_id=snap.id,
|
||||
ticker=ticker,
|
||||
name=position_name[:128] if position_name else None,
|
||||
quantity=p.quantity,
|
||||
average_price=p.average_price,
|
||||
current_price=p.current_price,
|
||||
ppl=p.result,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
return PersistResult(
|
||||
portfolio_id=portfolio.id,
|
||||
snapshot_id=snap.id,
|
||||
positions_written=len(pie.positions),
|
||||
unmapped_slices=unmapped,
|
||||
portfolio_name=name,
|
||||
is_new_portfolio=is_new,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue