# 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 `<script>` tag.
- `app/static/js/portfolio.js` — render hidden × button per row; replace empty-state CTA with the inline add form.
- `app/static/css/cassandra.css` — small block for edit-mode visibility + form styling.

**Reuse without modification:**
- `app/services/market.fetch_yahoo` — single-ticker quote fetcher.
- `app/services/market.Quote`, `_yahoo_range_covering`, `YAHOO_CHART`, `UA` — for `fetch_yahoo_historical`.
- `app/services/ticker_universe.upsert_tickers` — to seed the universe on validate.
- `app/services/access.require_paid` — the paid gate.
- `window.CassandraPortfolio.{loadPie, savePie, mountAndRender}` — already exposed.

---

## Test conventions

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
```

Project-isolated container; safe. Use `unittest.mock.patch` / `monkeypatch` on `app.routers.ticker_validate.fetch_yahoo` (and a new `fetch_yahoo_historical`) to avoid real HTTP. DB-touching tests follow the per-test factory pattern from `tests/test_referral_conversion.py::_build_session_factory` (also used in `tests/test_llm_csv_parser.py`).

**Important:** Both new endpoints write to the DB on the validate side-effect (upsert ticker_universe + insert Quote row). `ticker_universe.upsert_tickers` uses MySQL `ON DUPLICATE KEY UPDATE` which SQLite can't compile — mirror the mocking pattern used in `tests/test_llm_csv_parser.py::test_parse_portfolio_route_falls_through_to_llm`.

---

### Task 1: Validate endpoint — happy path + tests

**Files:**
- Create: `app/routers/ticker_validate.py`
- Test: `tests/test_ticker_validate.py`

- [ ] **Step 1: Write failing tests**

Create `tests/test_ticker_validate.py`:

```python
"""Tests for /api/ticker/validate and /api/ticker/historical."""
from __future__ import annotations

from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest


def _build_session_factory(tmp_path):
    """Spin up a fresh in-memory schema and return (engine, factory, setup).
    Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

    from app import db as db_mod
    from app.db import Base
    import app.models  # noqa: F401

    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
    factory = async_sessionmaker(engine, expire_on_commit=False)
    db_mod._engine = engine
    db_mod._session_factory = factory

    async def _setup():
        async with engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)

    return engine, factory, _setup


@pytest.mark.asyncio
async def test_validate_happy_path(tmp_path, monkeypatch):
    from app.routers.ticker_validate import validate_ticker
    from app.services.market import Quote
    import app.routers.ticker_validate as mod

    _, factory, setup = _build_session_factory(tmp_path)
    await setup()

    # Mock fetch_yahoo to return a successful quote.
    async def _fake_yahoo(client, symbol, label, note, anchor=None):
        return Quote(
            symbol=symbol, source="yahoo", label=label, note=note,
            price=172.40, currency="USD", as_of="2026-05-27", changes={},
        )
    monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)

    # Avoid the MySQL-only upsert on SQLite.
    async def _fake_upsert(session, tickers):
        return len(list(tickers))
    monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)

    async with factory() as session:
        result = await validate_ticker(symbol="aapl", session=session)

    assert result["ok"] is True
    assert result["symbol"] == "AAPL"
    assert result["price"] == 172.40
    assert result["currency"] == "USD"
    assert result["as_of"] == "2026-05-27"
```

- [ ] **Step 2: Run the test to verify it fails**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py::test_validate_happy_path -v
```

Expected: FAIL with `ImportError: cannot import name 'validate_ticker'`.

- [ ] **Step 3: Create the router with the validate endpoint**

Create `app/routers/ticker_validate.py`:

```python
"""Per-ticker validation + historical-price endpoints.

These power the dashboard's "Add a position" form. Neither endpoint
persists holdings — they wrap the existing Yahoo chart fetcher and
optionally seed anonymous ticker_universe (validate only).

Both endpoints are gated behind ``require_paid`` so they match the rest
of the import surface.
"""
from __future__ import annotations

from datetime import datetime, timezone

import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import require_auth
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import Quote as QuoteModel
from app.services.access import require_paid
from app.services.market import (
    UA, YAHOO_CHART, Quote, _yahoo_range_covering, fetch_yahoo,
)
from app.services.ticker_universe import upsert_tickers


log = get_logger("ticker_validate")

router = APIRouter(dependencies=[Depends(require_auth)])


@router.get(
    "/ticker/validate",
    dependencies=[Depends(require_paid)],
)
async def validate_ticker(
    symbol: str,
    session: AsyncSession = Depends(get_session),
) -> dict:
    """Live quote for one ticker.

    Returns ``{ok: true, symbol, price, currency, as_of}`` on success
    or ``{ok: false, error}`` when the symbol isn't recognised. Seeds
    ticker_universe + writes a Quote row as a side-effect on success
    so the dashboard's /api/universe call picks it up on the next
    refresh."""
    symbol = symbol.strip().upper()[:32]
    if not symbol:
        return {"ok": False, "error": "symbol required"}

    async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
        quote = await fetch_yahoo(client, symbol, symbol, "")

    if quote.error or quote.price is None:
        log.info("ticker.validate.miss", symbol=symbol,
                 error=(quote.error or "no price")[:120])
        return {"ok": False, "error": "Symbol not recognised"}

    # Side-effect: seed the universe + write the quote so /api/universe
    # has data on the next minute-cycle refresh.
    await upsert_tickers(session, [symbol])
    session.add(QuoteModel(
        symbol=quote.symbol, source=quote.source, label=quote.label,
        group_name="universe", price=quote.price, currency=quote.currency,
        as_of=quote.as_of, changes=quote.changes or None, error=None,
        fetched_at=utcnow(),
    ))
    await session.commit()

    return {
        "ok": True,
        "symbol": quote.symbol,
        "price": quote.price,
        "currency": quote.currency,
        "as_of": quote.as_of,
    }
```

- [ ] **Step 4: Run the test to verify it passes**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py::test_validate_happy_path -v
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/routers/ticker_validate.py tests/test_ticker_validate.py
git commit -m "ticker-validate: add /api/ticker/validate endpoint"
```

## Context for this task

- Working directory: `/home/gg/mydocker_images/products/read.markets`. Branch `main`. Commit directly.
- `fetch_yahoo` already swallows exceptions into `Quote.error`. We deliberately do NOT split provider-down vs symbol-unknown — both return `{ok: false}` with a generic message. The spec mentions 502 for provider-down, but UI value is marginal and `fetch_yahoo`'s broad-except makes the distinction fragile. (Document this deviation in the commit message if it changes the engineer's confidence; otherwise proceed.)
- If your `git commit` is blocked by the auto-mode classifier, leave the tree dirty and report — controller will commit.

---

### Task 2: Validate endpoint — failure path + side-effect tests

**Files:**
- Test: `tests/test_ticker_validate.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_ticker_validate.py`:

```python
@pytest.mark.asyncio
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
    from app.routers.ticker_validate import validate_ticker
    from app.services.market import Quote
    import app.routers.ticker_validate as mod

    _, factory, setup = _build_session_factory(tmp_path)
    await setup()

    # Mock fetch_yahoo to return a Quote with error and no price.
    async def _fake_yahoo(client, symbol, label, note, anchor=None):
        return Quote(symbol=symbol, source="yahoo", label=label, note=note,
                     price=None, currency=None, as_of=None,
                     error="empty result")
    monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)

    async with factory() as session:
        result = await validate_ticker(symbol="XYZNOTREAL", session=session)

    assert result["ok"] is False
    assert "not recognised" in result["error"].lower()


@pytest.mark.asyncio
async def test_validate_empty_symbol_rejects():
    from app.routers.ticker_validate import validate_ticker

    # Direct call — no session needed because we short-circuit before any DB use.
    result = await validate_ticker(symbol="   ", session=None)
    assert result["ok"] is False
    assert "required" in result["error"].lower()


@pytest.mark.asyncio
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
    """Side-effect check: on success, the symbol is upserted into the
    universe and a Quote row is written."""
    from sqlalchemy import select

    from app.models import Quote as QuoteModel
    from app.routers.ticker_validate import validate_ticker
    from app.services.market import Quote
    import app.routers.ticker_validate as mod

    _, factory, setup = _build_session_factory(tmp_path)
    await setup()

    upsert_calls: list[list[str]] = []

    async def _fake_yahoo(client, symbol, label, note, anchor=None):
        return Quote(symbol=symbol, source="yahoo", label=label, note=note,
                     price=100.0, currency="USD", as_of="2026-05-27", changes={})
    monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)

    async def _fake_upsert(session, tickers):
        upsert_calls.append(list(tickers))
        return len(list(tickers))
    monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)

    async with factory() as session:
        result = await validate_ticker(symbol="MSFT", session=session)

    assert result["ok"] is True
    assert upsert_calls == [["MSFT"]]

    # Quote row was written.
    async with factory() as session:
        rows = (await session.execute(
            select(QuoteModel).where(QuoteModel.symbol == "MSFT")
        )).scalars().all()
    assert len(rows) == 1
    assert rows[0].price == 100.0
    assert rows[0].currency == "USD"
```

- [ ] **Step 2: Run tests to verify they pass**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
```

Expected: all 4 tests in the file pass.

- [ ] **Step 3: Commit**

```bash
git add tests/test_ticker_validate.py
git commit -m "ticker-validate: cover failure + side-effect paths"
```

---

### Task 3: Historical endpoint + tests

**Files:**
- Modify: `app/routers/ticker_validate.py` (append `fetch_yahoo_historical` + endpoint)
- Test: `tests/test_ticker_validate.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_ticker_validate.py`:

```python
@pytest.mark.asyncio
async def test_historical_happy_path(monkeypatch):
    from app.routers.ticker_validate import get_historical
    import app.routers.ticker_validate as mod

    async def _fake_hist(client, symbol, target_iso):
        # close, currency, actual_iso
        return 185.92, "USD", "2024-01-12"
    monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)

    result = await get_historical(symbol="aapl", date="2024-01-15")

    assert result["ok"] is True
    assert result["close"] == 185.92
    assert result["currency"] == "USD"
    # 2024-01-15 was a Monday — but our fake says it walked back to Jan 12 (Fri).
    assert result["actual_date"] == "2024-01-12"


@pytest.mark.asyncio
async def test_historical_future_date_rejected():
    from fastapi import HTTPException
    from app.routers.ticker_validate import get_historical

    future = "2099-01-01"
    with pytest.raises(HTTPException) as exc:
        await get_historical(symbol="AAPL", date=future)
    assert exc.value.status_code == 400
    assert "future" in str(exc.value.detail).lower()


@pytest.mark.asyncio
async def test_historical_bad_date_format_rejected():
    from fastapi import HTTPException
    from app.routers.ticker_validate import get_historical

    with pytest.raises(HTTPException) as exc:
        await get_historical(symbol="AAPL", date="not-a-date")
    assert exc.value.status_code == 400


@pytest.mark.asyncio
async def test_historical_no_data(monkeypatch):
    from app.routers.ticker_validate import get_historical
    import app.routers.ticker_validate as mod

    async def _fake_hist(client, symbol, target_iso):
        return None, None, None
    monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)

    result = await get_historical(symbol="ZZZNEW", date="2020-01-15")
    assert result["ok"] is False
    assert "no data" in result["error"].lower()


@pytest.mark.asyncio
async def test_historical_provider_failure(monkeypatch):
    from app.routers.ticker_validate import get_historical
    import app.routers.ticker_validate as mod

    async def _fake_hist(client, symbol, target_iso):
        raise httpx.RequestError("connection failed")
    monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)

    import httpx  # noqa: F401 — used inside _fake_hist above

    result = await get_historical(symbol="AAPL", date="2024-01-15")
    assert result["ok"] is False
    assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()


@pytest.mark.asyncio
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
    """Unit test for the helper itself: feed a hand-crafted series with a
    weekend gap, ask for the Saturday close, expect Friday's close."""
    from app.routers.ticker_validate import fetch_yahoo_historical

    # Build a fake httpx client that returns a chart payload with a
    # Thu-Fri-Mon-Tue series; we ask for Saturday and expect Friday.
    thu_ts = int(datetime(2024, 1, 11, tzinfo=timezone.utc).timestamp())
    fri_ts = int(datetime(2024, 1, 12, tzinfo=timezone.utc).timestamp())
    mon_ts = int(datetime(2024, 1, 15, tzinfo=timezone.utc).timestamp())
    payload = {
        "chart": {"result": [{
            "meta": {"currency": "USD"},
            "timestamp": [thu_ts, fri_ts, mon_ts],
            "indicators": {"quote": [{"close": [184.0, 185.92, 186.10]}]},
        }]}
    }

    class _FakeResponse:
        def __init__(self, data): self._data = data
        def json(self): return self._data
        def raise_for_status(self): pass

    class _FakeClient:
        async def get(self, *args, **kwargs):
            return _FakeResponse(payload)

    close, currency, actual = await fetch_yahoo_historical(
        _FakeClient(), "AAPL", "2024-01-13",  # a Saturday
    )
    assert close == 185.92
    assert currency == "USD"
    assert actual == "2024-01-12"
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -k "historical or fetch_yahoo_historical" -v
```

Expected: FAIL with `ImportError` for `get_historical` and `fetch_yahoo_historical`.

- [ ] **Step 3: Append the helper + endpoint to `app/routers/ticker_validate.py`**

Add to `app/routers/ticker_validate.py` (under the existing imports + after `validate_ticker`):

```python
async def fetch_yahoo_historical(
    client: httpx.AsyncClient,
    symbol: str,
    target_iso: str,
) -> tuple[float | None, str | None, str | None]:
    """Fetch the close on ``target_iso`` or the nearest preceding trading
    day's close (within the available history window).

    Returns ``(close, currency, actual_iso)`` or ``(None, None, None)``
    when no usable data exists. Raises on provider-level HTTP errors
    (the caller wraps these into a friendly ``ok:false`` response).
    """
    range_param = _yahoo_range_covering(target_iso)
    r = await client.get(
        YAHOO_CHART.format(symbol=symbol),
        params={"interval": "1d", "range": range_param,
                "includePrePost": "false"},
        headers=UA,
        timeout=15,
    )
    r.raise_for_status()
    result = r.json().get("chart", {}).get("result")
    if not result:
        return None, None, None
    res = result[0]
    currency = (res.get("meta") or {}).get("currency")
    timestamps = res.get("timestamp") or []
    closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
    series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
    if not series:
        return None, None, None
    target_dt = datetime.strptime(target_iso, "%Y-%m-%d").replace(tzinfo=timezone.utc)
    # Add a 24h buffer so the target day itself is included (Yahoo
    # timestamps are at market open, not midnight).
    cutoff_ts = int(target_dt.timestamp()) + 86400
    selected: tuple[int, float] | None = None
    for t, c in series:
        if t <= cutoff_ts:
            selected = (t, c)
        else:
            break
    if selected is None:
        return None, None, None
    actual_iso = datetime.fromtimestamp(selected[0], timezone.utc).strftime("%Y-%m-%d")
    return selected[1], currency, actual_iso


@router.get(
    "/ticker/historical",
    dependencies=[Depends(require_paid)],
)
async def get_historical(symbol: str, date: str) -> dict:
    """Historical daily close. If ``date`` is a non-trading day we walk
    back to the last preceding trading day and surface ``actual_date``
    so the UI can show the user which date we actually used."""
    symbol = symbol.strip().upper()[:32]
    if not symbol:
        return {"ok": False, "error": "symbol required"}
    try:
        target = datetime.strptime(date, "%Y-%m-%d").date()
    except ValueError:
        raise HTTPException(status_code=400, detail="invalid date format (YYYY-MM-DD)")
    if target > datetime.now(timezone.utc).date():
        raise HTTPException(status_code=400, detail="date cannot be in the future")

    async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
        try:
            close, currency, actual = await fetch_yahoo_historical(client, symbol, date)
        except Exception as e:
            log.warning("ticker.historical.failed", symbol=symbol,
                        date=date, error=str(e)[:200])
            return {"ok": False, "error": "Couldn't fetch historical price"}

    if close is None:
        return {"ok": False, "error": "No data for that date"}
    return {"ok": True, "close": close, "currency": currency, "actual_date": actual}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
```

Expected: all tests in the file pass (10 total now).

- [ ] **Step 5: Commit**

```bash
git add app/routers/ticker_validate.py tests/test_ticker_validate.py
git commit -m "ticker-validate: add /api/ticker/historical with weekend-walkback"
```

---

### Task 4: Register the router + paid-gate inspection tests

**Files:**
- Modify: `app/main.py` (one new import + one include_router line near line 91)
- Test: `tests/test_ticker_validate.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_ticker_validate.py`:

```python
def test_validate_route_requires_paid():
    """Static check that the /ticker/validate route is gated by require_paid."""
    from app.routers.ticker_validate import router
    from app.services.access import require_paid

    route = next(
        r for r in router.routes
        if getattr(r, "path", "") == "/ticker/validate"
    )
    dep_callables = [d.call for d in route.dependant.dependencies]
    assert require_paid in dep_callables


def test_historical_route_requires_paid():
    from app.routers.ticker_validate import router
    from app.services.access import require_paid

    route = next(
        r for r in router.routes
        if getattr(r, "path", "") == "/ticker/historical"
    )
    dep_callables = [d.call for d in route.dependant.dependencies]
    assert require_paid in dep_callables
```

- [ ] **Step 2: Run the gate tests to verify they pass against the existing router**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -k requires_paid -v
```

Expected: 2 PASS. (No registration needed for the static check.)

- [ ] **Step 3: Register the router in `app/main.py`**

Find the existing router-import block (around lines 20-28) and add:

```python
from app.routers import ticker_validate as ticker_validate_router
```

Find the existing `app.include_router` block (around lines 88-103) and add (alongside the other `/api`-prefixed mounts near line 91):

```python
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
```

- [ ] **Step 4: Smoke-test the full route is reachable**

Append one more test to `tests/test_ticker_validate.py`:

```python
def test_routes_mounted_under_api_prefix():
    """Confirm the router is mounted on the FastAPI app under /api."""
    from app.main import app

    paths = {getattr(r, "path", "") for r in app.routes}
    assert "/api/ticker/validate" in paths
    assert "/api/ticker/historical" in paths
```

- [ ] **Step 5: Run all ticker-validate tests**

```bash
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
```

Expected: all 13 tests pass.

- [ ] **Step 6: Commit**

```bash
git add app/main.py tests/test_ticker_validate.py
git commit -m "ticker-validate: mount router at /api/ticker/*"
```

---

### Task 5: Dashboard markup — EDIT button + form scaffolding

**Files:**
- Modify: `app/templates/dashboard.html` — add EDIT button to the panel header; add the add-position form (hidden); register the new script.

- [ ] **Step 1: Replace the portfolio-panel block**

In `app/templates/dashboard.html`, find the existing portfolio panel (~line 47-59):

```html
<section id="portfolio-panel" class="panel">
  <div class="panel-header">
    <span class="title">Portfolio</span>
    <span class="meta">held locally · prices via /api/universe</span>
  </div>
  <div class="panel-body">
    <div id="pf-mount">
      <div class="empty">loading…</div>
    </div>
  </div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
```

Replace with:

```html
<section id="portfolio-panel" class="panel">
  <div class="panel-header">
    <span class="title">Portfolio</span>
    <span class="meta">held locally · prices via /api/universe</span>
    <button type="button" id="pf-edit-btn" class="pf-edit-btn"
            title="Add or remove positions" aria-pressed="false">
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
           stroke="currentColor" stroke-width="2" aria-hidden="true">
        <path d="M12 20h9"/>
        <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
      </svg>
      <span class="pf-edit-btn__label">Edit</span>
    </button>
    <button type="button" id="pf-done-btn" class="pf-done-btn" hidden>Done</button>
  </div>
  <div class="panel-body">
    <div id="pf-add-form" class="pf-add-form" hidden>
      <div class="pf-add-row">
        <label class="pf-add-field">
          <span class="pf-add-label">Ticker</span>
          <input type="text" id="pf-add-ticker" autocomplete="off"
                 spellcheck="false" maxlength="32" placeholder="AAPL">
          <span id="pf-add-ticker-status" class="pf-add-status"></span>
        </label>
        <label class="pf-add-field">
          <span class="pf-add-label">Quantity</span>
          <input type="number" id="pf-add-qty" min="0" step="any" placeholder="100">
        </label>
      </div>
      <div class="pf-add-row pf-add-cost-mode">
        <label class="pf-add-radio">
          <input type="radio" name="pf-cost-mode" value="avg" checked>
          Avg cost per share
        </label>
        <label class="pf-add-radio">
          <input type="radio" name="pf-cost-mode" value="date">
          Bought on date
        </label>
      </div>
      <div class="pf-add-row">
        <label class="pf-add-field" id="pf-add-date-field" hidden>
          <span class="pf-add-label">Acquisition date</span>
          <input type="date" id="pf-add-date">
          <span id="pf-add-date-status" class="pf-add-status"></span>
        </label>
        <label class="pf-add-field">
          <span class="pf-add-label">Cost per share</span>
          <input type="number" id="pf-add-cost" min="0" step="any" placeholder="150.25">
          <span id="pf-add-cost-currency" class="pf-add-currency"></span>
        </label>
        <button type="button" id="pf-add-submit" class="pf-add-submit" disabled>
          + Add
        </button>
      </div>
      <div id="pf-add-warning" class="pf-add-warning" hidden></div>
    </div>
    <div id="pf-mount">
      <div class="empty">loading…</div>
    </div>
  </div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}" defer></script>
```

- [ ] **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 "<tr\|tr class\|td.*ticker\|td.*qty\|<td " app/static/js/portfolio.js | head -20
```

Find the function that emits `<tr>` 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 `<td>` to each row's HTML:

```javascript
'<td class="pf-row-del-cell">' +
  '<button type="button" class="pf-row-del" data-idx="' + i + '" ' +
          'title="Remove this position" aria-label="Remove">×</button>' +
'</td>'
```

(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 `<tr>`.)

Also add a matching empty `<th></th>` 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
      ? '<div class="empty-notice">Your encrypted cloud backup expired. ' +
          'Please re-upload your portfolio to refresh it.' +
        '</div>'
      : '';
    var panel = document.getElementById('portfolio-panel');
    if (panel) panel.classList.add('pf-empty');
    mount.innerHTML =
      '<div class="empty" style="padding:16px;">' +
        notice +
        'Welcome — start by adding a position above, or ' +
        '<a href="/settings#import">import a CSV from your broker →</a>' +
      '</div>';
    // 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.
