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>
This commit is contained in:
Giorgio Gilestro 2026-05-27 14:23:23 +02:00
parent bc55ab7d26
commit 4c92d8a3e7

View file

@ -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 `<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.