# 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 `