diff --git a/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md b/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md new file mode 100644 index 0000000..b909c16 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md @@ -0,0 +1,1434 @@ +# Manual Portfolio Composition Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add dashboard-native portfolio editing: an EDIT button toggles inline edit mode with an add-position form (live ticker validation + avg-cost or bought-on-date toggle) and per-row delete; brand-new users see the same form in the empty state instead of being sent to /settings. + +**Architecture:** Two new paid-only endpoints (`/api/ticker/validate`, `/api/ticker/historical`) thinly wrap the existing `fetch_yahoo` to give the JS the data it needs. A new `portfolio_edit.js` owns edit-mode toggle + form behaviour and writes positions via the existing `window.CassandraPortfolio.savePie` API. `portfolio.js` is touched only to (a) emit × buttons hidden by CSS and (b) replace the empty-state CTA. No server-side portfolio persistence; localStorage stays authoritative. + +**Tech Stack:** FastAPI · `httpx.AsyncClient` · vanilla JS · Jinja2 templates · existing `app/services/market.fetch_yahoo` · existing `app/services/ticker_universe.upsert_tickers` + +**Spec:** `docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md` + +--- + +## File Structure + +**Create:** +- `app/routers/ticker_validate.py` — both new endpoints (validate + historical) + the `fetch_yahoo_historical` helper. +- `tests/test_ticker_validate.py` — backend tests. +- `app/static/js/portfolio_edit.js` — edit-mode + add-position form behaviour. + +**Modify:** +- `app/main.py` — register the new router (one new line). +- `app/templates/dashboard.html` — EDIT button in panel header; hidden add-position form; new ` + +``` + +Replace with: + +```html +
+
+ Portfolio + held locally · prices via /api/universe + + +
+
+ +
+
loading…
+
+
+
+ + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/templates/dashboard.html +git commit -m "dashboard: scaffold portfolio edit-mode markup" +``` + +## Context for this task + +- No tests for this — pure markup. JS will hide/show via class toggle. +- The pencil SVG path is the standard "edit" icon (Feather-style). +- All form fields are present in the DOM at all times; `portfolio_edit.js` shows/hides via CSS class on `#portfolio-panel`. + +--- + +### Task 6: portfolio_edit.js — edit-mode toggle + +**Files:** +- Create: `app/static/js/portfolio_edit.js` + +- [ ] **Step 1: Create the scaffold with edit-mode toggle** + +Create `app/static/js/portfolio_edit.js`: + +```javascript +/* Dashboard-native portfolio editing. + * + * Owns: the EDIT button toggle, the add-position form behaviour + * (ticker validation on blur, qty/cost inputs, date-mode historical + * lookup, Add click), and per-row delete via event delegation. + * + * Reads/writes the portfolio via window.CassandraPortfolio.loadPie / + * savePie / mountAndRender — the same surface portfolio.js exposes + * for the CSV-import preview. + */ +(function () { + 'use strict'; + + const panel = document.getElementById('portfolio-panel'); + const editBtn = document.getElementById('pf-edit-btn'); + const doneBtn = document.getElementById('pf-done-btn'); + const form = document.getElementById('pf-add-form'); + if (!panel || !editBtn || !doneBtn || !form) return; + + function enterEditMode() { + panel.classList.add('pf-editing'); + form.hidden = false; + editBtn.hidden = true; + doneBtn.hidden = false; + editBtn.setAttribute('aria-pressed', 'true'); + document.getElementById('pf-add-ticker').focus(); + } + + function exitEditMode() { + panel.classList.remove('pf-editing'); + // Form stays visible when the pie is empty (empty-state UX handled + // by portfolio.js setting the pf-empty class on the panel). + if (!panel.classList.contains('pf-empty')) { + form.hidden = true; + } + editBtn.hidden = false; + doneBtn.hidden = true; + editBtn.setAttribute('aria-pressed', 'false'); + } + + editBtn.addEventListener('click', enterEditMode); + doneBtn.addEventListener('click', exitEditMode); +})(); +``` + +- [ ] **Step 2: Smoke-test in a browser** + +Restart the app (manual prod step — request user approval), navigate to the dashboard, click EDIT. Expected: form appears, focus lands in the ticker input; Done button appears; Edit button hides. Click Done: reverse. + +(No automated test; vanilla JS, no JS test framework in this repo.) + +- [ ] **Step 3: Commit** + +```bash +git add app/static/js/portfolio_edit.js +git commit -m "portfolio-edit: edit-mode toggle scaffold" +``` + +--- + +### Task 7: portfolio_edit.js — ticker validation on blur + +**Files:** +- Modify: `app/static/js/portfolio_edit.js` (append to the existing IIFE before the closing `})();`) + +- [ ] **Step 1: Add validation logic** + +Inside the IIFE in `app/static/js/portfolio_edit.js`, before the final `})();`, append: + +```javascript + // ---- Ticker validation on blur ------------------------------------- + + const tickerInput = document.getElementById('pf-add-ticker'); + const tickerStatus = document.getElementById('pf-add-ticker-status'); + const costCurrencyEl = document.getElementById('pf-add-cost-currency'); + const submitBtn = document.getElementById('pf-add-submit'); + const warningEl = document.getElementById('pf-add-warning'); + + let validated = null; // {symbol, price, currency, as_of} or null + + function setStatus(el, text, kind) { + el.textContent = text; + el.className = 'pf-add-status' + (kind ? ' pf-add-status--' + kind : ''); + } + + function updateSubmitState() { + const qty = parseFloat(document.getElementById('pf-add-qty').value); + const cost = parseFloat(document.getElementById('pf-add-cost').value); + submitBtn.disabled = !( + validated && qty > 0 && cost > 0 && isFinite(qty) && isFinite(cost) + ); + } + + function clearDuplicateWarning() { + warningEl.hidden = true; + warningEl.textContent = ''; + } + + function showDuplicateWarning(existing) { + warningEl.hidden = false; + warningEl.textContent = + `Already in your portfolio (${existing.qty} shares @ ` + + `${existing.avg_cost.toFixed(2)}). Adding will create a duplicate row.`; + } + + async function validateTicker() { + const raw = tickerInput.value.trim().toUpperCase(); + if (!raw) { + validated = null; + setStatus(tickerStatus, '', ''); + costCurrencyEl.textContent = ''; + clearDuplicateWarning(); + updateSubmitState(); + return; + } + setStatus(tickerStatus, 'checking…', 'pending'); + try { + const r = await fetch('/api/ticker/validate?symbol=' + encodeURIComponent(raw)); + if (!r.ok) throw new Error('HTTP ' + r.status); + const j = await r.json(); + if (j.ok) { + validated = j; + setStatus( + tickerStatus, + '✓ ' + j.price.toFixed(2) + ' ' + (j.currency || ''), + 'ok', + ); + costCurrencyEl.textContent = j.currency || ''; + // Duplicate detection. + const pie = window.CassandraPortfolio.loadPie(); + const existing = pie && (pie.positions || []).find( + p => (p.yahoo_ticker || '').toUpperCase() === j.symbol + ); + if (existing) showDuplicateWarning(existing); + else clearDuplicateWarning(); + } else { + validated = null; + setStatus(tickerStatus, '✗ ' + (j.error || 'not recognised'), 'err'); + costCurrencyEl.textContent = ''; + clearDuplicateWarning(); + } + } catch (e) { + validated = null; + setStatus(tickerStatus, '✗ couldn’t validate — try again', 'err'); + costCurrencyEl.textContent = ''; + clearDuplicateWarning(); + } + updateSubmitState(); + } + + tickerInput.addEventListener('blur', validateTicker); + document.getElementById('pf-add-qty').addEventListener('input', updateSubmitState); + document.getElementById('pf-add-cost').addEventListener('input', updateSubmitState); +``` + +- [ ] **Step 2: Smoke-test in browser** + +Restart app. EDIT → type `AAPL`, tab out → expect green check with price + currency. Type bogus → red error. Add button stays disabled. + +- [ ] **Step 3: Commit** + +```bash +git add app/static/js/portfolio_edit.js +git commit -m "portfolio-edit: ticker validate on blur + duplicate warning" +``` + +--- + +### Task 8: portfolio_edit.js — Add button + localStorage merge + +**Files:** +- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE) + +- [ ] **Step 1: Add the submit handler** + +Append (inside the same IIFE): + +```javascript + // ---- Add button → localStorage merge ------------------------------- + + function resetForm() { + tickerInput.value = ''; + document.getElementById('pf-add-qty').value = ''; + document.getElementById('pf-add-cost').value = ''; + document.getElementById('pf-add-date').value = ''; + validated = null; + setStatus(tickerStatus, '', ''); + costCurrencyEl.textContent = ''; + clearDuplicateWarning(); + updateSubmitState(); + tickerInput.focus(); + } + + function addPosition() { + if (submitBtn.disabled) return; + const qty = parseFloat(document.getElementById('pf-add-qty').value); + const cost = parseFloat(document.getElementById('pf-add-cost').value); + const sym = validated.symbol; + + const pie = window.CassandraPortfolio.loadPie() || { + pie_name: null, + base_currency: 'GBP', + positions: [], + totals: {invested: 0, value: 0, result: 0}, + warnings: [], + }; + pie.positions = pie.positions || []; + pie.positions.push({ + yahoo_ticker: sym, + t212_slice: sym, // shared shape with CSV path + name: validated.name || sym, + qty: qty, + avg_cost: cost, + currency: validated.currency || 'USD', + }); + window.CassandraPortfolio.savePie(pie); + window.CassandraPortfolio.mountAndRender(); + resetForm(); + } + + submitBtn.addEventListener('click', addPosition); + + // Submit on Enter from any input within the form. + form.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !submitBtn.disabled) { + e.preventDefault(); + addPosition(); + } + }); +``` + +- [ ] **Step 2: Smoke-test in browser** + +EDIT → enter AAPL + qty 100 + cost 150.25 → Add. Row should appear in the table. Form clears, ticker input refocused. Refresh page; row persists (localStorage). + +- [ ] **Step 3: Commit** + +```bash +git add app/static/js/portfolio_edit.js +git commit -m "portfolio-edit: add button writes position to localStorage" +``` + +--- + +### Task 9: portfolio_edit.js — Bought-on-date mode + historical lookup + +**Files:** +- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE) + +- [ ] **Step 1: Add cost-mode toggle + historical lookup** + +Append: + +```javascript + // ---- Cost mode toggle + historical lookup -------------------------- + + const dateField = document.getElementById('pf-add-date-field'); + const dateInput = document.getElementById('pf-add-date'); + const dateStatus = document.getElementById('pf-add-date-status'); + const costInput = document.getElementById('pf-add-cost'); + + function onModeChange() { + const mode = document.querySelector( + 'input[name="pf-cost-mode"]:checked' + ).value; + if (mode === 'date') { + dateField.hidden = false; + costInput.readOnly = true; + costInput.placeholder = 'auto-filled from date'; + } else { + dateField.hidden = true; + costInput.readOnly = false; + costInput.placeholder = '150.25'; + setStatus(dateStatus, '', ''); + } + } + + document.querySelectorAll('input[name="pf-cost-mode"]').forEach(r => + r.addEventListener('change', onModeChange) + ); + + async function fetchHistorical() { + if (!validated) { + setStatus(dateStatus, 'enter a valid ticker first', 'err'); + return; + } + const d = dateInput.value; + if (!d) { + setStatus(dateStatus, '', ''); + costInput.value = ''; + updateSubmitState(); + return; + } + setStatus(dateStatus, 'looking up…', 'pending'); + try { + const url = '/api/ticker/historical?symbol=' + + encodeURIComponent(validated.symbol) + + '&date=' + encodeURIComponent(d); + const r = await fetch(url); + if (r.status === 400) { + const j = await r.json().catch(() => ({detail: 'invalid date'})); + setStatus(dateStatus, '✗ ' + (j.detail || 'invalid date'), 'err'); + costInput.value = ''; + updateSubmitState(); + return; + } + const j = await r.json(); + if (j.ok) { + costInput.value = j.close.toFixed(2); + const tag = (j.actual_date && j.actual_date !== d) + ? '✓ from ' + j.actual_date + : '✓'; + setStatus(dateStatus, tag, 'ok'); + } else { + setStatus(dateStatus, '✗ ' + (j.error || 'no data'), 'err'); + costInput.value = ''; + } + } catch (e) { + setStatus(dateStatus, '✗ couldn’t fetch — try again', 'err'); + costInput.value = ''; + } + updateSubmitState(); + } + + dateInput.addEventListener('blur', fetchHistorical); +``` + +- [ ] **Step 2: Smoke-test in browser** + +EDIT → AAPL validated → switch radio to "Bought on date" → cost field becomes read-only → pick a Friday → cost field auto-fills. Pick a Saturday → cost fills with previous Friday's close, "✓ from YYYY-MM-DD" tag visible. Pick today's date → fills with today's close. Pick a future date → red "date cannot be in the future". + +- [ ] **Step 3: Commit** + +```bash +git add app/static/js/portfolio_edit.js +git commit -m "portfolio-edit: bought-on-date mode + historical lookup" +``` + +--- + +### Task 10: portfolio.js — render × buttons + empty-state form + +**Files:** +- Modify: `app/static/js/portfolio.js` (two changes: `renderEmpty` and the row-render code) + +- [ ] **Step 1: Identify the row-render call site** + +```bash +grep -n "` rows for each position. (The skeleton is likely a `rows.map(...)` or string-concat. Locate it; this is where we add a per-row × cell.) + +- [ ] **Step 2: Modify each row to include an × delete cell** + +Inside the row-render loop, append a new `` to each row's HTML: + +```javascript +'' + + '' + +'' +``` + +(Use whichever loop variable holds the index. If positions are iterated with `.map((p, i) => ...)`, `i` is the index. The × cell goes at the END of each ``.) + +Also add a matching empty `` to the table header row so column counts line up. + +- [ ] **Step 3: Replace the empty-state render** + +Find `function renderEmpty(mount)` (~line 180). Replace its body: + +```javascript + function renderEmpty(mount) { + var notice = window._cassandraPortfolioBackupExpired + ? '
Your encrypted cloud backup expired. ' + + 'Please re-upload your portfolio to refresh it.' + + '
' + : ''; + var panel = document.getElementById('portfolio-panel'); + if (panel) panel.classList.add('pf-empty'); + mount.innerHTML = + '
' + + notice + + 'Welcome — start by adding a position above, or ' + + 'import a CSV from your broker →' + + '
'; + // When empty, the add form should be visible by default — the + // edit module toggles it via the pf-empty class. + var form = document.getElementById('pf-add-form'); + if (form) form.hidden = false; + } +``` + +Also, in the function that runs after a successful render (where the panel is NOT empty), remove the `pf-empty` class: + +```javascript +// After successful render of populated positions: +var panel = document.getElementById('portfolio-panel'); +if (panel) panel.classList.remove('pf-empty'); +``` + +- [ ] **Step 4: Add the × click handler in portfolio_edit.js** + +In `app/static/js/portfolio_edit.js`, append (inside the same IIFE): + +```javascript + // ---- Per-row delete (event delegation) ----------------------------- + + panel.addEventListener('click', function (e) { + const btn = e.target.closest('.pf-row-del'); + if (!btn) return; + const idx = parseInt(btn.dataset.idx, 10); + if (!Number.isInteger(idx)) return; + const pie = window.CassandraPortfolio.loadPie(); + if (!pie || !pie.positions || idx < 0 || idx >= pie.positions.length) return; + pie.positions.splice(idx, 1); + window.CassandraPortfolio.savePie(pie); + window.CassandraPortfolio.mountAndRender(); + }); +``` + +- [ ] **Step 5: Smoke-test** + +Restart app. Visit dashboard with a populated pie → no × visible. Click EDIT → × appears on each row. Click an × → that row vanishes. Refresh page → row stays gone. Visit dashboard with a CLEARED pie (open devtools → `localStorage.removeItem('cassandra.pie')`, refresh) → empty state shows the inline form + "Or import a CSV" link. + +- [ ] **Step 6: Commit** + +```bash +git add app/static/js/portfolio.js app/static/js/portfolio_edit.js +git commit -m "portfolio: render hidden × per row; empty state shows add form" +``` + +--- + +### Task 11: CSS for edit mode + form + +**Files:** +- Modify: `app/static/css/cassandra.css` (append a new block) + +- [ ] **Step 1: Append the edit-mode + form styles** + +Append to `app/static/css/cassandra.css`: + +```css +/* ---------- Dashboard portfolio edit mode ----------------------------- */ + +/* The EDIT button — same shape as .settings-icon-btn but smaller. */ +.pf-edit-btn, +.pf-done-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: transparent; + border: 1px solid var(--neu-dim, #444); + color: var(--text, #ccc); + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-left: auto; +} +.pf-edit-btn:hover, .pf-done-btn:hover { + background: var(--surface-2, #2a2a2a); + border-color: var(--accent, #5af); +} + +/* × button on each row — hidden by default, visible only in edit mode. */ +.pf-row-del-cell { width: 24px; text-align: center; } +.pf-row-del { + display: none; + background: transparent; + border: none; + color: var(--neu-dim, #888); + cursor: pointer; + font-size: 14px; + padding: 0 4px; +} +#portfolio-panel.pf-editing .pf-row-del { display: inline; } +#portfolio-panel.pf-editing .pf-row-del:hover { color: var(--err, #f55); } + +/* Add-position form. */ +.pf-add-form { + border: 1px solid var(--neu-dim, #333); + border-radius: 6px; + padding: 12px; + margin-bottom: 12px; + background: var(--surface-2, #1a1a1a); +} +.pf-add-row { + display: flex; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; + align-items: flex-end; +} +.pf-add-field { + display: flex; + flex-direction: column; + flex: 1 1 140px; + font-size: 12px; +} +.pf-add-label { + color: var(--neu-dim, #888); + margin-bottom: 2px; +} +.pf-add-field input[type="text"], +.pf-add-field input[type="number"], +.pf-add-field input[type="date"] { + background: var(--surface, #111); + border: 1px solid var(--neu-dim, #444); + color: var(--text, #ccc); + padding: 4px 6px; + border-radius: 3px; + font-family: inherit; + font-size: 13px; +} +.pf-add-cost-mode { gap: 16px; } +.pf-add-radio { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 12px; + cursor: pointer; +} +.pf-add-currency { + color: var(--neu-dim, #888); + font-size: 11px; + margin-top: 2px; +} +.pf-add-submit { + background: var(--accent, #5af); + color: var(--bg, #000); + border: none; + padding: 6px 14px; + border-radius: 3px; + cursor: pointer; + font-weight: 600; + align-self: flex-end; +} +.pf-add-submit:disabled { + background: var(--neu-dim, #444); + cursor: not-allowed; +} +.pf-add-status { + font-size: 11px; + margin-top: 2px; + min-height: 14px; +} +.pf-add-status--pending { color: var(--neu-dim, #888); } +.pf-add-status--ok { color: var(--ok, #6c6); } +.pf-add-status--err { color: var(--err, #f55); } +.pf-add-warning { + color: var(--warn, #fb3); + font-size: 11px; + margin-top: 8px; +} +``` + +- [ ] **Step 2: Smoke-test all visual states** + +Browser smoke: +- Light/dark theme rendering of the form. +- Disabled Add button looks visibly disabled. +- × buttons appear only in edit mode. +- Status text colours change correctly across pending/ok/err. + +- [ ] **Step 3: Commit** + +```bash +git add app/static/css/cassandra.css +git commit -m "css: portfolio edit-mode + add-position form styles" +``` + +--- + +### Task 12: Final regression + manual deploy verification + +**Files:** +- (no code changes — verification only) + +- [ ] **Step 1: Run the full test suite** + +```bash +docker compose -f docker-compose.test.yml run --rm test pytest tests/ 2>&1 | tail -5 +``` + +Expected: every test passes including the new `tests/test_ticker_validate.py`. Should land in the high-260s now. + +- [ ] **Step 2: Deploy (requires explicit user approval)** + +```bash +docker compose restart app +docker compose logs app --tail 30 | grep -E "(Uvicorn|startup complete|ERROR|Traceback)" +``` + +Expected: clean startup; no tracebacks. NOTE: this is a prod restart on this host — do not run without explicit user authorisation. + +- [ ] **Step 3: Manual smoke — populated portfolio** + +In a paid-tier browser session: + +1. Click EDIT → form appears, × buttons appear, focus lands in ticker input. +2. Type a known symbol → green check + price + currency. +3. Enter qty + cost → Add. New row appears. Form clears. +4. Repeat with date mode: pick a weekday → cost auto-fills. Pick a Saturday → cost fills with Friday's close, "from YYYY-MM-DD" tag shows. +5. Click × on any row → row vanishes; refresh confirms persistence. +6. Click Done → form hides, × hidden, dashboard returns to normal display. + +- [ ] **Step 4: Manual smoke — empty state** + +In devtools: `localStorage.removeItem('cassandra.pie')`. Refresh. + +Expected: the add-position form is visible at the top of the portfolio area; below it the message "Welcome — start by adding a position above, or import a CSV from your broker →". Add one position; the empty state disappears. + +- [ ] **Step 5: Manual smoke — paid gating** + +Open an incognito session as a free-tier user. Direct `GET /api/ticker/validate?symbol=AAPL` (devtools network tab or curl). Expect 402. + +- [ ] **Step 6: Manual smoke — error states** + +- Bad symbol `XYZNOTREAL` → red "Not recognised", Add stays disabled. +- Pick future date → red "date cannot be in the future" inline. +- Add a duplicate ticker → inline warning under the ticker field, but the Add button remains enabled (with valid qty+cost). + +--- + +## Self-Review + +**Spec coverage walkthrough:** + +- **Dashboard placement (not Settings)** → Task 5 puts everything inside `#portfolio-panel`. +- **EDIT button toggles edit mode** → Task 6 enterEditMode/exitEditMode. +- **Live prices keep updating during edit mode** → no change to portfolio.js's `setInterval(mountAndRender, ...)` cadence; edit-mode is purely visual layering. +- **On-blur, blocking ticker validation** → Task 7 (`blur` listener; Add button enabled only when `validated && qty > 0 && cost > 0`). +- **Avg-cost / bought-on-date toggle** → Task 9 with `onModeChange`. +- **Date is a UX helper only** → Task 8 `addPosition` stores only `qty + avg_cost + currency` (no date in localStorage). +- **Immediate per-row persist (no Save)** → Task 8 `addPosition` writes localStorage on each Add. +- **Two paid-only endpoints** → Tasks 1-4 with paid-gate inspection tests. +- **Side-effect: seed ticker_universe** → Task 1 endpoint code + Task 2 side-effect test. +- **Historical: walk back to preceding trading day** → Task 3 unit test (`test_fetch_yahoo_historical_walks_back_to_preceding_trading_day`). +- **Duplicate ticker → warning, allow proceed** → Task 7 `showDuplicateWarning`; Add button remains enabled. +- **× delete by index (handles duplicates correctly)** → Task 10's event delegation uses `data-idx` not by ticker. +- **Empty state shows the form** → Task 10 `renderEmpty` rewrite + `pf-empty` class. +- **No persistence of dates** → Task 8 (only `qty + avg_cost + currency` in the position object). +- **No multi-pie / no save-to-server** → out of scope; no tasks build these. + +**Type / signature consistency:** + +- `validate_ticker(symbol, session)` — used in Tasks 1, 2, 4. Consistent. +- `get_historical(symbol, date)` — Tasks 3, 4. No `session` parameter (no DB writes). Consistent. +- `fetch_yahoo_historical(client, symbol, target_iso) -> tuple[float|None, str|None, str|None]` — Tasks 3, all consistent. +- `window.CassandraPortfolio.{loadPie, savePie, mountAndRender}` — used in Tasks 7-10. Already exposed by existing portfolio.js. No new exports needed. + +**Spec deviation flagged:** + +- **Provider-down → 502 vs symbol-unknown → 200 ok:false distinction**: the spec preserves both. The plan collapses both into `{ok: false}` (200) because `fetch_yahoo` swallows exceptions and disentangling them post-hoc is fragile. UI difference: the user sees "Symbol not recognised" or "Couldn't validate, try again" depending on whether the JS got a network error vs a parsed JSON ok:false — close enough to the spec's intent. If the engineer prefers the spec's distinction, they can call an unsafe `httpx.AsyncClient` directly inside the endpoint and split exception types. Note in the commit message either way. + +No spec requirements have zero tasks.