From 4c92d8a3e7df6eb6a98dd0debb42dfccfcdcd7be Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 14:23:23 +0200 Subject: [PATCH] docs: spec for manual portfolio composition Dashboard-native edit mode: EDIT button toggles in-place editing; the add-position form has on-blur ticker validation against a new paid endpoint, qty input, and an avg-cost / bought-on-date toggle. Only avg_cost + qty are persisted to localStorage (no acquisition date, no server-side holdings). Empty state replaces "Import a CSV" with the inline form so brand-new users can act without leaving the page. Co-Authored-By: Claude Opus 4.7 --- ...-27-manual-portfolio-composition-design.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md diff --git a/docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md b/docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md new file mode 100644 index 0000000..a1a020b --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md @@ -0,0 +1,312 @@ +# Manual portfolio composition — Design Spec + +**Date:** 2026-05-27 +**Status:** Draft — pending implementation plan + +## Context + +Today the only way to populate the dashboard portfolio is to upload a CSV +(Trading 212 natively, anything else via the LLM-fallback parser landed in +the spec at `2026-05-27-llm-csv-fallback-parser-design.md`). That covers +users who already keep their holdings in a broker that can export. It does +not cover: + +- New users who want to try the dashboard with a handful of holdings without + hunting for an export feature in their broker. +- Existing users who bought something after their last export and want to + add a single position without re-importing. +- Users whose broker provides no usable export (some legacy ISAs, employer + share schemes, niche EU brokers). + +This feature adds a **dashboard-native edit mode**. A pencil-icon EDIT +button next to the portfolio heading toggles inline editing: each row +reveals a × delete button, and a small "Add a position" form appears at +the top of the portfolio block. Live prices continue updating while +editing. Brand-new users (no portfolio in localStorage) see the add form +directly in place of the existing "No portfolio loaded — import a CSV" +message. + +The CSV import flow in `/settings` is unchanged. + +## Goals + +- Let the user add a position to their dashboard portfolio in under 15 + seconds without leaving the page. +- Sanity-check tickers before they hit localStorage, so the dashboard never + shows symbols that won't price. +- Stay consistent with the existing data-flow invariant: the browser + localStorage owns the portfolio; the server persists no holdings. +- Accept either an average-cost-per-share or a "I bought on date X" input; + both produce the same stored shape (`avg_cost + qty`). + +## Non-goals + +- Multi-pie / named portfolios. Single localStorage pie remains the model. +- Persisting acquisition dates. The date input is a UX helper that + populates the cost field; only `avg_cost + qty` are stored. +- In-place edit of an existing row's qty or cost. Delete + re-add. +- Saving the portfolio to the server. localStorage stays the source of + truth. +- A separate manual-entry section on `/settings`. The feature lives on the + dashboard only. + +## Architecture + +``` +Dashboard (/) + ├─ Portfolio div + │ ├─ [✎ Edit] button (toggles edit mode in place) + │ ├─ Add-position form (visible only in edit mode, or always when empty) + │ │ ├─ Ticker input → on blur → GET /api/ticker/validate + │ │ ├─ Qty input + │ │ ├─ Cost mode toggle: ● Avg cost / ○ Bought on date + │ │ ├─ Cost field (number, or date-picker that auto-fills cost + │ │ │ via GET /api/ticker/historical) + │ │ └─ [Add] button (enabled only when validated + qty > 0 + cost > 0) + │ └─ Positions table + │ └─ Per-row [×] (visible only in edit mode) + └─ (everything else unchanged) +``` + +Two new **paid-only** endpoints are added; both wrap the existing +`app/services/market.fetch` machinery and do not persist holdings. The +edit-mode JavaScript merges new positions directly into the same +localStorage pie that the CSV import path writes. + +### Why dashboard-native, not Settings + +Editing a portfolio is a portfolio-management action. Putting it on the +dashboard puts the affordance where users naturally look — alongside the +thing they're editing. It also makes the empty-state CTA actionable in +place rather than redirecting to another page first. + +### Why two endpoints instead of one + +`/api/ticker/validate` returns the current quote — that's the live signal +the user wants for sanity reassurance ("yes, this symbol exists, and it's +worth $X today"). `/api/ticker/historical` returns the close on a chosen +date — only needed when the user picks "Bought on date" mode. Splitting +them keeps each endpoint's contract tight; combining would force the +common-case validate call to ship unused historical machinery. + +## Data flow + +### Validation flow (every add) + +1. User types `AAPL` and tabs out of the ticker field. +2. JS calls `GET /api/ticker/validate?symbol=AAPL`. +3. Server calls `market.fetch` for a single ticker. If a quote comes back, + returns `{ok: true, name: "Apple Inc", currency: "USD", price: 172.40, + as_of: "2026-05-27"}`. Side-effect: seeds anonymous `ticker_universe` + (same as the CSV path does). +4. JS shows a green inline check + `Apple Inc · $172.40 USD`. +5. Subsequent fields enable; Add button enables when all required filled. + +### Historical-price flow (only in "Bought on date" mode) + +1. User selects a date in the picker (e.g. `2024-01-15`). +2. On blur, JS calls `GET /api/ticker/historical?symbol=AAPL&date=2024-01-15`. +3. Server fetches the daily close for that date from Yahoo. If the date is + a non-trading day (weekend / holiday), uses the **last preceding trading + day** and returns its actual date. +4. Returns `{ok: true, close: 185.92, currency: "USD", actual_date: "2024-01-12"}`. +5. JS auto-fills the cost field with the close, shows a subtle "from 2024-01-12" + tag the user can dismiss. User can edit the auto-filled number. + +### Add flow + +1. User clicks **+ Add**. +2. JS reads localStorage pie (or creates a fresh `{positions: []}` shape if + none), appends a new position object: + ```json + { + "yahoo_ticker": "AAPL", + "t212_slice": "AAPL", + "name": "Apple Inc", + "qty": 100, + "avg_cost": 150.25, + "currency": "USD" + } + ``` +3. Writes back to localStorage. +4. Re-renders the positions table; the new row appears with live price + columns populating on the next `/api/universe` refresh. +5. Clears the form, focus returns to the ticker input for rapid serial entry. + +### Delete flow + +1. User clicks `×` on a row. +2. JS removes that position by index (not by ticker — so duplicates can be + removed independently). +3. Writes localStorage, re-renders. + +No undo. A confirmation dialog would be friction; the row can be re-added +in 10 seconds. + +## Server endpoints + +Both **require `Depends(require_paid)`** — matches the existing import +path. Anonymous / free-tier users get 402. + +### `GET /api/ticker/validate?symbol={t}` + +| Field | Meaning | +|---|---| +| `symbol` (query) | The ticker the user typed. Server uppercases + strips. Length capped at 32 chars. | + +Returns JSON: + +```json +{ + "ok": true, + "name": "Apple Inc", + "currency": "USD", + "price": 172.40, + "as_of": "2026-05-27" +} +``` + +or on unrecognised symbol: + +```json +{ "ok": false, "error": "Symbol not recognised" } +``` + +The endpoint returns 200 with `ok:false` for unrecognised symbols (so the +JS can render an inline error without parsing HTTP status). It returns 502 +for upstream provider failures (so the JS shows "Try again" rather than +"Not recognised"). + +Side effect: on `ok:true`, the symbol is upserted into anonymous +`ticker_universe` so the next `/api/universe` request includes its price. + +### `GET /api/ticker/historical?symbol={t}&date={YYYY-MM-DD}` + +| Field | Meaning | +|---|---| +| `symbol` (query) | Same shape as validate. | +| `date` (query) | ISO date the user picked. Server validates format + rejects future dates with 400. | + +Returns JSON: + +```json +{ + "ok": true, + "close": 185.92, + "currency": "USD", + "actual_date": "2024-01-12" +} +``` + +If the requested date is a non-trading day, the server walks back to the +last preceding day with a quote (up to a 7-day window) and returns +`actual_date` for transparency. + +On a symbol that's never had price data on the platform or before the +ticker's earliest available date: + +```json +{ "ok": false, "error": "No data for that date" } +``` + +## Client-side modules + +### `app/static/js/portfolio_edit.js` (NEW) + +A small module loaded by the dashboard template. Owns: + +- The `enterEditMode()` / `exitEditMode()` toggle. +- The add-position form's event wiring (validate-on-blur, mode toggle, + historical lookup, Add click). +- The × button click handler. +- A `mergePosition(pos)` helper that takes a position object, reads + localStorage, appends, writes back, dispatches a custom `portfolio:changed` + event for the existing portfolio.js renderer to pick up. + +### `app/static/js/portfolio.js` (MODIFIED, lightly) + +Three small additions: + +1. Empty-state CTA replaced. Instead of `"No portfolio loaded in this + browser. Import a portfolio CSV →"` linking to settings, the empty + state shows the inline add-position form (the same markup that edit + mode reveals) plus a small secondary link: `"Or import a CSV from your + broker →"` to `/settings#import`. +2. Listen for `portfolio:changed` and re-render. +3. Expose `window.CassandraPortfolio.merge(pos)` so `portfolio_edit.js` + can call it without circular module dependencies. + +### `app/templates/dashboard.html` (MODIFIED) + +Add: +- The `[✎ Edit]` button next to the portfolio heading. +- The add-position form markup (hidden by default; visible in edit mode + via a CSS class on the parent container, or always visible when the + pie is empty). +- A `