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:
parent
bc55ab7d26
commit
4c92d8a3e7
1 changed files with 312 additions and 0 deletions
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue