read.markets/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md
Giorgio Gilestro ae3f104fa7 docs: implementation plan for manual portfolio composition
12 TDD-style tasks: two backend endpoints (validate + historical),
router registration, dashboard markup, and five JS slices building the
edit-mode behaviour (toggle → ticker validate → Add → date-mode →
delete via delegation). CSS pass and final manual smoke close it out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:36:32 +02:00

50 KiB
Raw Permalink Blame History

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

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:

"""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
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:

"""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
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
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:

@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
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
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:

@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
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):

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
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
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:

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
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:

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):

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:

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
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
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):

<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:

<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
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:

/* 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
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:

  // ---- 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, '✗ couldnt 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
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):

  // ---- 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
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:

  // ---- 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, '✗ couldnt 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
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

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:

'<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:

  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:

// 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):

  // ---- 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
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:

/* ---------- 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

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

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)
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.