read.markets/docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md
Giorgio Gilestro 4c92d8a3e7 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 <noreply@anthropic.com>
2026-05-27 14:23:23 +02:00

12 KiB
Raw Blame History

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:
    {
      "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:

{
  "ok": true,
  "name": "Apple Inc",
  "currency": "USD",
  "price": 172.40,
  "as_of": "2026-05-27"
}

or on unrecognised symbol:

{ "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:

{
  "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:

{ "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 <script src="/static/js/portfolio_edit.js" defer> tag.

Error / edge cases

Case Response
Yahoo provider down on validate 502 → JS shows "Couldn't validate, try again."
Symbol not recognised 200 {ok:false} → red inline "Not recognised"
Date in future 400 → JS shows "Date can't be in the future"
Date before earliest data 200 {ok:false} → "No data for that date"
Duplicate ticker (already in pie) JS shows inline warning under the ticker field: "Already in your portfolio (100 shares @ $150.25). Adding will create a duplicate row." User can proceed; we do not auto-merge.
Currency mismatch when adding two lots of the same symbol Not detected. Yahoo gives one currency per ticker; both rows get the same.
User refreshes the page mid-edit Form state is lost; no draft preserved. Edit mode itself is not preserved either — the page loads in display mode by default.

Tests

Backend (tests/test_ticker_validate.py):

  • validate happy path → 200 {ok:true} with mocked market.fetch.
  • validate unknown symbol (mock returns no price) → 200 {ok:false}.
  • validate provider failure → 502.
  • validate paid-gate inspection (same pattern as the CSV route's gate test).
  • historical happy path → 200 {ok:true, close, currency, actual_date}.
  • historical future-date → 400.
  • historical weekend date → returns nearest preceding trading day, actual_date reflects it.
  • historical paid-gate inspection.
  • validate side-effect: ticker is upserted into ticker_universe (assert by querying the table after the call).

Frontend: untested (this repo has no JS test framework). Manual smoke covers the JS paths in the verification section.

Verification

End-to-end manual check after deploy:

  1. Empty-state path: log in as a user with no localStorage portfolio. Confirm the dashboard shows the inline add form, not the old "Import a CSV" link.
  2. Add a position: enter AAPL, tab out, see "Apple Inc · $X USD" green check. Enter qty 100, avg cost 150.25, click Add. Row appears in the table with live price column populating shortly after.
  3. Switch to Bought on date mode for a second add: enter MSFT, tab, green check. Pick a recent weekday. Cost field auto-fills with the close. Click Add; row appears.
  4. Pick a Saturday: confirm the form shows from 2024-01-12 (or whichever Friday) tag.
  5. Try a bogus symbol like XYZNOTREAL: red "Not recognised", Add stays disabled.
  6. Pick a future date: red inline error, Add stays disabled.
  7. Edit mode round-trip: with a populated pie, click EDIT → × buttons appear, add form appears. Delete a row, add another, confirm both reflect on the next dashboard refresh.
  8. Free-tier user: confirm a direct GET /api/ticker/validate?symbol=AAPL returns 402.
  9. Confirm csv_format_templates is untouched (no overlap between features).

Out-of-scope clarifications

  • We do not implement an in-place "edit this row's qty" UX. Users who averaged down can delete and re-add the position with the new average.
  • We do not persist acquisition dates anywhere — only avg_cost + qty reach localStorage. The date is a UX helper for filling the cost field.
  • We do not offer to fetch the historical close for an "avg cost" position. Date mode is the only path that triggers the historical lookup.
  • We do not introduce a "Save to server" affordance. localStorage remains authoritative.