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>
12 KiB
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 + qtyare 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)
- User types
AAPLand tabs out of the ticker field. - JS calls
GET /api/ticker/validate?symbol=AAPL. - Server calls
market.fetchfor 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 anonymousticker_universe(same as the CSV path does). - JS shows a green inline check +
Apple Inc · $172.40 USD. - Subsequent fields enable; Add button enables when all required filled.
Historical-price flow (only in "Bought on date" mode)
- User selects a date in the picker (e.g.
2024-01-15). - On blur, JS calls
GET /api/ticker/historical?symbol=AAPL&date=2024-01-15. - 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.
- Returns
{ok: true, close: 185.92, currency: "USD", actual_date: "2024-01-12"}. - 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
- User clicks + Add.
- 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" } - Writes back to localStorage.
- Re-renders the positions table; the new row appears with live price
columns populating on the next
/api/universerefresh. - Clears the form, focus returns to the ticker input for rapid serial entry.
Delete flow
- User clicks
×on a row. - JS removes that position by index (not by ticker — so duplicates can be removed independently).
- 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 customportfolio:changedevent for the existing portfolio.js renderer to pick up.
app/static/js/portfolio.js (MODIFIED, lightly)
Three small additions:
- 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. - Listen for
portfolio:changedand re-render. - Expose
window.CassandraPortfolio.merge(pos)soportfolio_edit.jscan 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):
validatehappy path → 200{ok:true}with mockedmarket.fetch.validateunknown symbol (mock returns no price) → 200{ok:false}.validateprovider failure → 502.validatepaid-gate inspection (same pattern as the CSV route's gate test).historicalhappy path → 200{ok:true, close, currency, actual_date}.historicalfuture-date → 400.historicalweekend date → returns nearest preceding trading day,actual_datereflects it.historicalpaid-gate inspection.validateside-effect: ticker is upserted intoticker_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:
- 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.
- Add a position: enter
AAPL, tab out, see "Apple Inc · $X USD" green check. Enter qty100, avg cost150.25, click Add. Row appears in the table with live price column populating shortly after. - 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. - Pick a Saturday: confirm the form shows
from 2024-01-12(or whichever Friday) tag. - Try a bogus symbol like
XYZNOTREAL: red "Not recognised", Add stays disabled. - Pick a future date: red inline error, Add stays disabled.
- 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.
- Free-tier user: confirm a direct
GET /api/ticker/validate?symbol=AAPLreturns 402. - Confirm
csv_format_templatesis 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 + qtyreach 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.