# fxhnt Cockpit Dashboard 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:** Build a read-only, Postgres-backed cockpit that shows the whole paper-forward fleet + factory population, fed by a daily in-cluster k8s CronJob, served at `dashboard.fxhnt.ai` behind a Tailscale proxy.

**Architecture:** Hexagonal, inside `fxhnt`. A `state_reader` adapter normalizes the heterogeneous `*_state.json` tracker files into a common read-model (`ForwardSummary` always; per-day `ForwardNavRow[]` when available). `forward_ingest` writes these into the in-cluster TimescaleDB (`forward_nav` hypertable + `forward_summary` + `strategy_registry`). A `dashboard_service` queries the DB into view-models; a FastAPI+HTMX `web` adapter renders them. A CronJob runs fetch+trackers+ingest; a Deployment serves the app.

**Tech Stack:** Python 3.11, typer (CLI), SQLAlchemy 2.0 (ORM, SQLite for tests / TimescaleDB pg16 in prod), FastAPI + Jinja2 + HTMX + inline SVG (no JS build), pytest/hypothesis (TDD), Docker/Kaniko + k8s (Scaleway), Tailscale per-service proxy.

---

## File Structure

**Create (fxhnt):**
- `src/fxhnt/application/forward_models.py` — `ForwardNavRow`, `ForwardSummary`, `GateVerdict` dataclasses (shared DTOs).
- `src/fxhnt/adapters/persistence/state_reader.py` — `ForwardStateReader`: parse each `*_state.json` family → `(ForwardSummary, list[ForwardNavRow])`.
- `src/fxhnt/adapters/persistence/cockpit_models.py` — ORM: `ForwardNavRow`(table), `ForwardSummaryRow`, `StrategyRegistryRow`.
- `src/fxhnt/adapters/persistence/forward_nav.py` — `ForwardNavRepo`: idempotent upsert of rows + summary; migration (create tables + hypertable).
- `src/fxhnt/application/forward_ingest.py` — `ForwardIngestService`: state_dir → repo.
- `src/fxhnt/application/gate.py` — `evaluate_gate(summary, gate_spec) -> GateVerdict`.
- `src/fxhnt/ports/dashboard.py` — `DashboardReadModel` protocol + view-model dataclasses.
- `src/fxhnt/application/dashboard_service.py` — `DashboardService`: DB → fleet/detail/factory view-models.
- `src/fxhnt/adapters/web/__init__.py`, `app.py`, `charts.py` — FastAPI app + inline-SVG charts.
- `src/fxhnt/adapters/web/templates/{base,cockpit,strategy,factory}.html` — Jinja2 + HTMX.
- `src/fxhnt/adapters/web/static/fxhnt.png` — logo (copied from `~/Work/foxhunt/fxhnt.png`).
- `src/fxhnt/registry.py` — checked-in `STRATEGY_REGISTRY` seed (display names, sleeves, gate specs).
- `infra/docker/fxhnt.Dockerfile`, `infra/docker/.dockerignore`
- `infra/k8s/jobs/fxhnt-forward-cronjob.yaml`, `infra/k8s/services/fxhnt-dashboard.yaml`

**Modify (fxhnt):**
- `pyproject.toml` — add `web` and `pg` optional-dependency groups.
- `src/fxhnt/cli.py` — add `migrate-cockpit`, `ingest-forward`, `serve` commands.

**Tests:**
- `tests/unit/test_state_reader.py`, `test_gate.py`, `test_charts.py`
- `tests/integration/test_forward_nav_repo.py`, `test_forward_ingest.py`, `test_dashboard_service.py`, `test_web.py`

---

# Milestone 1 — Data spine

### Task 1: Add optional dependency groups

**Files:**
- Modify: `pyproject.toml`

- [ ] **Step 1: Add `web` and `pg` groups**

In `[project.optional-dependencies]`, after the `data = [...]` line, add:

```toml
web = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "jinja2>=3.1", "httpx>=0.27"]
pg = ["psycopg[binary]>=3.1"]
```

(`httpx` is needed by FastAPI's `TestClient`.)

- [ ] **Step 2: Install and verify import**

Run: `pip install -e '.[dev,web,pg]'`
Expected: success; then `python -c "import fastapi, jinja2, psycopg, uvicorn; print('ok')"` prints `ok`.

- [ ] **Step 3: Commit**

```bash
git add pyproject.toml
git commit -m "build(cockpit): add web + pg optional-dependency groups"
```

---

### Task 2: Shared read-model DTOs

**Files:**
- Create: `src/fxhnt/application/forward_models.py`
- Test: `tests/unit/test_state_reader.py` (created here, extended in Task 3)

- [ ] **Step 1: Write the failing test**

Create `tests/unit/test_state_reader.py`:

```python
"""The forward read-model DTOs and the state_reader that normalizes heterogeneous tracker state files."""
from __future__ import annotations

from fxhnt.application.forward_models import ForwardNavRow, ForwardSummary


def test_forward_summary_total_return_pct() -> None:
    s = ForwardSummary(strategy_id="x", as_of="2026-06-12", days=7, nav=1.05,
                       total_return=0.05, sharpe=1.2, maxdd=-0.04)
    assert round(s.total_return_pct, 2) == 5.0


def test_forward_nav_row_is_immutable() -> None:
    r = ForwardNavRow(strategy_id="x", date="2026-06-05", ret=0.01, nav=1.01)
    assert r.strategy_id == "x" and r.nav == 1.01
```

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

Run: `pytest tests/unit/test_state_reader.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.application.forward_models'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/application/forward_models.py`:

```python
"""Shared forward-tracking read-model DTOs. The state_reader produces these; ingest persists them; the
dashboard reads them back. One ForwardSummary per strategy is ALWAYS available; the per-day ForwardNavRow
series is only rich for trackers that record a days-list (others build it forward, one point per ingest)."""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class ForwardNavRow:
    strategy_id: str
    date: str          # ISO yyyy-mm-dd
    ret: float         # that day's fractional return (0.0 if unknown for snapshot-only trackers)
    nav: float         # cumulative NAV (1.0 base)


@dataclass(frozen=True, slots=True)
class ForwardSummary:
    strategy_id: str
    as_of: str         # last date represented
    days: int          # forward days elapsed
    nav: float
    total_return: float
    sharpe: float
    maxdd: float       # <= 0

    @property
    def total_return_pct(self) -> float:
        return 100.0 * self.total_return


@dataclass(frozen=True, slots=True)
class GateVerdict:
    status: str        # "WAIT" | "GO" | "NO_GO"
    reason: str
```

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

Run: `pytest tests/unit/test_state_reader.py -v`
Expected: PASS (2 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/forward_models.py tests/unit/test_state_reader.py
git commit -m "feat(cockpit): forward read-model DTOs (ForwardSummary, ForwardNavRow, GateVerdict)"
```

---

### Task 3: state_reader — normalize the three state families

**Files:**
- Create: `src/fxhnt/adapters/persistence/state_reader.py`
- Test: `tests/unit/test_state_reader.py` (extend)

The three families, detected by structure:
- **days-list** (`fxhnt_combined_forward`): `{"inception","last_date","nav","days":[{"date","ret","nav"},...]}` — `days` is a **list** → full per-day rows.
- **welford-equity** (`multistrat`, `sixtyforty`, `gd_paper`, `poc`): `{"last_date","days":int,"sum_r","sumsq_r","equity","peak","max_dd"}` — aggregates only.
- **cumulative-funding** (`funding_paper`, `crossvenue`): `{"positions",...,"cum_gross","cum_net","days":int,"last_run_date"}` — cumulative only.

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

Append to `tests/unit/test_state_reader.py`:

```python
import json
import math

from fxhnt.adapters.persistence.state_reader import ForwardStateReader


def _write(tmp_path, name, obj):
    p = tmp_path / f"{name}.json"
    p.write_text(json.dumps(obj))
    return p


def test_reads_days_list_family(tmp_path) -> None:
    _write(tmp_path, "fxhnt_combined_forward", {
        "inception": "2026-06-04", "last_date": "2026-06-06", "nav": 1.0201,
        "days": [
            {"date": "2026-06-05", "ret": 0.01, "nav": 1.01},
            {"date": "2026-06-06", "ret": 0.0099, "nav": 1.0201},
        ],
    })
    reader = ForwardStateReader()
    summary, rows = reader.read(tmp_path / "fxhnt_combined_forward.json", "combined")
    assert summary.strategy_id == "combined"
    assert summary.days == 2
    assert summary.as_of == "2026-06-06"
    assert math.isclose(summary.nav, 1.0201, rel_tol=1e-9)
    assert [r.date for r in rows] == ["2026-06-05", "2026-06-06"]
    assert all(r.strategy_id == "combined" for r in rows)


def test_reads_welford_equity_family(tmp_path) -> None:
    _write(tmp_path, "multistrat_state", {
        "last_date": "2026-06-12", "days": 5, "sum_r": -0.0007245543702343088,
        "sumsq_r": 1.8807003187214318e-05, "equity": 0.9992663073361069,
        "peak": 1.001482883251981, "max_dd": -0.004599760548340659,
    })
    reader = ForwardStateReader()
    summary, rows = reader.read(tmp_path / "multistrat_state.json", "multistrat")
    assert summary.days == 5
    assert summary.as_of == "2026-06-12"
    assert math.isclose(summary.nav, 0.9992663073361069, rel_tol=1e-9)
    assert math.isclose(summary.total_return, -0.0007336926638931, rel_tol=1e-6)
    assert math.isclose(summary.maxdd, -0.004599760548340659, rel_tol=1e-9)
    # snapshot-only: one forward point at as_of with nav == equity
    assert len(rows) == 1 and rows[0].date == "2026-06-12"
    assert math.isclose(rows[0].nav, 0.9992663073361069, rel_tol=1e-9)


def test_reads_cumulative_funding_family(tmp_path) -> None:
    _write(tmp_path, "crossvenue_state", {
        "positions": {"LAB": 0.2, "TON": 0.2}, "cum_gross": 0.0046, "cum_net": -6.11e-05,
        "days": 8, "last_run_date": "2026-06-14",
    })
    reader = ForwardStateReader()
    summary, rows = reader.read(tmp_path / "crossvenue_state.json", "crossvenue")
    assert summary.days == 8
    assert summary.as_of == "2026-06-14"
    assert math.isclose(summary.total_return, -6.11e-05, rel_tol=1e-9)
    assert math.isclose(summary.nav, 1.0 - 6.11e-05, rel_tol=1e-9)
    assert summary.sharpe == 0.0          # not derivable from cumulative-only
    assert len(rows) == 1 and rows[0].date == "2026-06-14"


def test_unknown_shape_raises_valueerror(tmp_path) -> None:
    _write(tmp_path, "weird", {"foo": 1})
    reader = ForwardStateReader()
    try:
        reader.read(tmp_path / "weird.json", "weird")
        assert False, "expected ValueError"
    except ValueError:
        pass
```

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

Run: `pytest tests/unit/test_state_reader.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.adapters.persistence.state_reader'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/adapters/persistence/state_reader.py`:

```python
"""Normalize the heterogeneous paper-tracker *_state.json files into the common forward read-model.

Three families exist (detected by structure):
  * days-list       — has a list under "days": full per-day [{date,ret,nav}] series (e.g. combined book).
  * welford-equity  — has "equity" + Welford "sum_r"/"sumsq_r": aggregates only (e.g. multistrat, 60/40).
  * cumulative-funding — has "cum_net" + "positions": cumulative carry only (e.g. funding, crossvenue).

Only the days-list family yields a real per-day history. The others yield one forward point at their as_of
date (nav = current equity); repeated daily ingests build their forward series going forward."""
from __future__ import annotations

import json
import math
from pathlib import Path

from fxhnt.application.forward_models import ForwardNavRow, ForwardSummary

_ANN = math.sqrt(365.0)   # calendar-daily annualization (crypto trades weekends; close enough for futures)


class ForwardStateReader:
    def read(self, path: str | Path, strategy_id: str) -> tuple[ForwardSummary, list[ForwardNavRow]]:
        state = json.loads(Path(path).read_text())
        if isinstance(state.get("days"), list):
            return self._days_list(state, strategy_id)
        if "equity" in state:
            return self._welford(state, strategy_id)
        if "cum_net" in state:
            return self._funding(state, strategy_id)
        raise ValueError(f"unrecognized tracker state shape for {strategy_id}: keys={sorted(state)}")

    def _days_list(self, state: dict, sid: str) -> tuple[ForwardSummary, list[ForwardNavRow]]:
        days = state["days"]
        rows = [ForwardNavRow(strategy_id=sid, date=d["date"], ret=float(d["ret"]), nav=float(d["nav"]))
                for d in days]
        nav = float(state.get("nav", rows[-1].nav if rows else 1.0))
        as_of = state.get("last_date") or (rows[-1].date if rows else "")
        rets = [r.ret for r in rows]
        sharpe = self._sharpe(rets)
        maxdd = self._maxdd([r.nav for r in rows])
        summary = ForwardSummary(strategy_id=sid, as_of=as_of, days=len(rows), nav=nav,
                                 total_return=nav - 1.0, sharpe=sharpe, maxdd=maxdd)
        return summary, rows

    def _welford(self, state: dict, sid: str) -> tuple[ForwardSummary, list[ForwardNavRow]]:
        days = int(state.get("days", 0))
        equity = float(state["equity"])
        as_of = state.get("last_date", "")
        mean = float(state["sum_r"]) / days if days else 0.0
        var = (float(state["sumsq_r"]) / days - mean * mean) if days else 0.0
        std = math.sqrt(var) if var > 0 else 0.0
        sharpe = (mean / std * _ANN) if std > 0 else 0.0
        summary = ForwardSummary(strategy_id=sid, as_of=as_of, days=days, nav=equity,
                                 total_return=equity - 1.0, sharpe=sharpe,
                                 maxdd=float(state.get("max_dd", 0.0)))
        rows = [ForwardNavRow(strategy_id=sid, date=as_of, ret=0.0, nav=equity)] if as_of else []
        return summary, rows

    def _funding(self, state: dict, sid: str) -> tuple[ForwardSummary, list[ForwardNavRow]]:
        days = int(state.get("days", 0))
        cum_net = float(state["cum_net"])
        as_of = state.get("last_run_date", "")
        nav = 1.0 + cum_net
        summary = ForwardSummary(strategy_id=sid, as_of=as_of, days=days, nav=nav,
                                 total_return=cum_net, sharpe=0.0, maxdd=0.0)
        rows = [ForwardNavRow(strategy_id=sid, date=as_of, ret=0.0, nav=nav)] if as_of else []
        return summary, rows

    @staticmethod
    def _sharpe(rets: list[float]) -> float:
        n = len(rets)
        if n < 2:
            return 0.0
        mean = sum(rets) / n
        var = sum((x - mean) ** 2 for x in rets) / (n - 1)
        std = math.sqrt(var)
        return (mean / std * _ANN) if std > 0 else 0.0

    @staticmethod
    def _maxdd(nav: list[float]) -> float:
        if not nav:
            return 0.0
        peak = nav[0]
        mdd = 0.0
        for v in nav:
            peak = max(peak, v)
            mdd = min(mdd, v / peak - 1.0)
        return mdd
```

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

Run: `pytest tests/unit/test_state_reader.py -v`
Expected: PASS (6 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/persistence/state_reader.py tests/unit/test_state_reader.py
git commit -m "feat(cockpit): state_reader normalizes the three tracker state families"
```

---

### Task 4: Cockpit ORM models + strategy registry seed

**Files:**
- Create: `src/fxhnt/adapters/persistence/cockpit_models.py`
- Create: `src/fxhnt/registry.py`
- Test: `tests/integration/test_forward_nav_repo.py` (created here, extended in Task 5)

- [ ] **Step 1: Write the failing test**

Create `tests/integration/test_forward_nav_repo.py`:

```python
"""Cockpit ORM tables create cleanly and the registry seed is well-formed."""
from __future__ import annotations

from sqlalchemy import create_engine, inspect

from fxhnt.adapters.persistence.cockpit_models import CockpitBase
from fxhnt.registry import STRATEGY_REGISTRY


def test_cockpit_tables_create() -> None:
    eng = create_engine("sqlite://", future=True)
    CockpitBase.metadata.create_all(eng)
    tables = set(inspect(eng).get_table_names())
    assert {"forward_nav", "forward_summary", "strategy_registry"} <= tables


def test_registry_entries_have_required_fields() -> None:
    assert STRATEGY_REGISTRY, "registry must not be empty"
    for sid, e in STRATEGY_REGISTRY.items():
        assert e["display_name"] and e["sleeve"] and "state_file" in e
        assert "gate_spec" in e and "min_days" in e["gate_spec"]
```

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

Run: `pytest tests/integration/test_forward_nav_repo.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.adapters.persistence.cockpit_models'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/adapters/persistence/cockpit_models.py`:

```python
"""Cockpit ORM models — its own Declarative Base so create_all is independent of the factory's Base.
JSON columns work on both SQLite (tests) and Postgres (prod). The forward_nav table becomes a TimescaleDB
hypertable in prod via a guarded DDL call (see forward_nav.py); on SQLite it stays a plain table."""
from __future__ import annotations

import datetime as dt

from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class CockpitBase(DeclarativeBase):
    pass


class ForwardNavRow(CockpitBase):
    __tablename__ = "forward_nav"
    strategy_id: Mapped[str] = mapped_column(String(64), primary_key=True)
    date: Mapped[str] = mapped_column(String(10), primary_key=True)   # ISO yyyy-mm-dd
    ret: Mapped[float] = mapped_column(Float)
    nav: Mapped[float] = mapped_column(Float)
    src_updated_at: Mapped[dt.datetime] = mapped_column(DateTime)


class ForwardSummaryRow(CockpitBase):
    __tablename__ = "forward_summary"
    strategy_id: Mapped[str] = mapped_column(String(64), primary_key=True)
    as_of: Mapped[str] = mapped_column(String(10))
    days: Mapped[int] = mapped_column(Integer)
    nav: Mapped[float] = mapped_column(Float)
    total_return: Mapped[float] = mapped_column(Float)
    sharpe: Mapped[float] = mapped_column(Float)
    maxdd: Mapped[float] = mapped_column(Float)
    gate_status: Mapped[str] = mapped_column(String(8), default="WAIT")
    gate_reason: Mapped[str] = mapped_column(String(256), default="")
    src_updated_at: Mapped[dt.datetime] = mapped_column(DateTime)


class StrategyRegistryRow(CockpitBase):
    __tablename__ = "strategy_registry"
    strategy_id: Mapped[str] = mapped_column(String(64), primary_key=True)
    display_name: Mapped[str] = mapped_column(String(96))
    sleeve: Mapped[str] = mapped_column(String(32))
    venue: Mapped[str] = mapped_column(String(32), default="")
    capital_base: Mapped[float] = mapped_column(Float, default=0.0)
    gate_spec: Mapped[dict] = mapped_column(JSON)
    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
```

Create `src/fxhnt/registry.py`:

```python
"""Checked-in registry of paper-forward trackers — maps each technical tracker to what the user sees plus
its Phase-1 gate thresholds. Adding a strategy = one entry here + (if a new state shape) a state_reader case.
`state_file` is the basename (without .json) under the trackers' state directory."""
from __future__ import annotations

# gate_spec keys: min_days, min_total_return (fraction), min_sharpe.
STRATEGY_REGISTRY: dict[str, dict] = {
    "combined": {
        "display_name": "Combined book (crypto-mom + futures-trend)", "sleeve": "multi",
        "venue": "binance+glbx", "state_file": "fxhnt_combined_forward",
        "gate_spec": {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.5},
    },
    "funding": {
        "display_name": "Crypto funding harvest (delta-neutral)", "sleeve": "carry",
        "venue": "binance", "state_file": "funding_paper_state",
        "gate_spec": {"min_days": 28, "min_total_return": 0.0, "min_sharpe": 0.0},
    },
    "crossvenue": {
        "display_name": "Cross-venue funding", "sleeve": "carry",
        "venue": "multi", "state_file": "crossvenue_state",
        "gate_spec": {"min_days": 28, "min_total_return": 0.0, "min_sharpe": 0.0},
    },
    "multistrat": {
        "display_name": "Multi-strategy book", "sleeve": "multi",
        "venue": "glbx", "state_file": "multistrat_state",
        "gate_spec": {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.3},
    },
    "sixtyforty": {
        "display_name": "60/40 baseline", "sleeve": "beta",
        "venue": "glbx", "state_file": "sixtyforty_state",
        "gate_spec": {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.0},
    },
    "gd": {
        "display_name": "GD paper", "sleeve": "multi",
        "venue": "glbx", "state_file": "gd_paper_state",
        "gate_spec": {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.3},
    },
    "poc": {
        "display_name": "Proof-of-concept book", "sleeve": "multi",
        "venue": "glbx", "state_file": "poc_state",
        "gate_spec": {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.3},
    },
}
```

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

Run: `pytest tests/integration/test_forward_nav_repo.py -v`
Expected: PASS (2 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/persistence/cockpit_models.py src/fxhnt/registry.py tests/integration/test_forward_nav_repo.py
git commit -m "feat(cockpit): ORM tables + checked-in strategy registry seed"
```

---

### Task 5: ForwardNavRepo — idempotent upsert + migration

**Files:**
- Create: `src/fxhnt/adapters/persistence/forward_nav.py`
- Test: `tests/integration/test_forward_nav_repo.py` (extend)

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

Append to `tests/integration/test_forward_nav_repo.py`:

```python
import datetime as dt

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.forward_models import ForwardNavRow as Row
from fxhnt.application.forward_models import ForwardSummary


def _repo() -> ForwardNavRepo:
    repo = ForwardNavRepo("sqlite://")   # shared in-memory engine held by the repo
    repo.migrate()
    return repo


def test_upsert_rows_is_idempotent() -> None:
    repo = _repo()
    rows = [Row("combined", "2026-06-05", 0.01, 1.01), Row("combined", "2026-06-06", 0.0099, 1.0201)]
    at = dt.datetime(2026, 6, 14, 23, 30)
    repo.upsert_rows(rows, at)
    repo.upsert_rows(rows, at)                       # second time = no dupes
    got = repo.nav_history("combined")
    assert [r.date for r in got] == ["2026-06-05", "2026-06-06"]


def test_upsert_rows_updates_existing_value() -> None:
    repo = _repo()
    at = dt.datetime(2026, 6, 14, 23, 30)
    repo.upsert_rows([Row("x", "2026-06-05", 0.01, 1.01)], at)
    repo.upsert_rows([Row("x", "2026-06-05", 0.02, 1.02)], at)   # same key, new value
    got = repo.nav_history("x")
    assert len(got) == 1 and got[0].nav == 1.02


def test_upsert_summary_and_fetch() -> None:
    repo = _repo()
    s = ForwardSummary("x", "2026-06-12", 7, 1.05, 0.05, 1.2, -0.04)
    repo.upsert_summary(s, "GO", "7d ok", dt.datetime(2026, 6, 14))
    got = {r.strategy_id: r for r in repo.all_summaries()}
    assert got["x"].nav == 1.05 and got["x"].gate_status == "GO"
```

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

Run: `pytest tests/integration/test_forward_nav_repo.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.adapters.persistence.forward_nav'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/adapters/persistence/forward_nav.py`:

```python
"""Repository for the cockpit's forward tables. Idempotent upserts (re-ingest is safe). `migrate()` creates
the tables and, on Postgres only, promotes forward_nav to a TimescaleDB hypertable (guarded; on SQLite it
stays a plain table so tests run without Postgres)."""
from __future__ import annotations

import datetime as dt

from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

from fxhnt.adapters.persistence.cockpit_models import (
    CockpitBase,
    ForwardNavRow,
    ForwardSummaryRow,
    StrategyRegistryRow,
)
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.registry import STRATEGY_REGISTRY


class ForwardNavRepo:
    def __init__(self, dsn: str) -> None:
        # StaticPool keeps a single in-memory SQLite connection alive across sessions (tests); harmless on pg.
        kw = {"future": True}
        if dsn.startswith("sqlite://") and ":memory:" in dsn or dsn == "sqlite://":
            kw |= {"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
        self._engine = create_engine(dsn, **kw)
        self._is_pg = self._engine.dialect.name == "postgresql"

    def migrate(self) -> None:
        CockpitBase.metadata.create_all(self._engine)
        if self._is_pg:
            with self._engine.begin() as c:
                c.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
                c.execute(text(
                    "SELECT create_hypertable('forward_nav', by_range('date'), if_not_exists => TRUE)"
                ))
        self._seed_registry()

    def _seed_registry(self) -> None:
        with Session(self._engine) as s:
            for sid, e in STRATEGY_REGISTRY.items():
                s.merge(StrategyRegistryRow(
                    strategy_id=sid, display_name=e["display_name"], sleeve=e["sleeve"],
                    venue=e.get("venue", ""), capital_base=float(e.get("capital_base", 0.0)),
                    gate_spec=e["gate_spec"], enabled=True))
            s.commit()

    def upsert_rows(self, rows: list[NavRowDTO], at: dt.datetime) -> None:
        with Session(self._engine) as s:
            for r in rows:
                s.merge(ForwardNavRow(strategy_id=r.strategy_id, date=r.date, ret=r.ret, nav=r.nav,
                                      src_updated_at=at))
            s.commit()

    def upsert_summary(self, sm: ForwardSummary, gate_status: str, gate_reason: str,
                       at: dt.datetime) -> None:
        with Session(self._engine) as s:
            s.merge(ForwardSummaryRow(
                strategy_id=sm.strategy_id, as_of=sm.as_of, days=sm.days, nav=sm.nav,
                total_return=sm.total_return, sharpe=sm.sharpe, maxdd=sm.maxdd,
                gate_status=gate_status, gate_reason=gate_reason, src_updated_at=at))
            s.commit()

    def nav_history(self, strategy_id: str) -> list[NavRowDTO]:
        stmt = (select(ForwardNavRow).where(ForwardNavRow.strategy_id == strategy_id)
                .order_by(ForwardNavRow.date))
        with Session(self._engine) as s:
            return [NavRowDTO(strategy_id=r.strategy_id, date=r.date, ret=r.ret, nav=r.nav)
                    for r in s.scalars(stmt)]

    def all_summaries(self) -> list[ForwardSummaryRow]:
        with Session(self._engine) as s:
            return list(s.scalars(select(ForwardSummaryRow).order_by(ForwardSummaryRow.strategy_id)))

    def registry(self) -> list[StrategyRegistryRow]:
        with Session(self._engine) as s:
            return list(s.scalars(select(StrategyRegistryRow).order_by(StrategyRegistryRow.strategy_id)))
```

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

Run: `pytest tests/integration/test_forward_nav_repo.py -v`
Expected: PASS (5 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/persistence/forward_nav.py tests/integration/test_forward_nav_repo.py
git commit -m "feat(cockpit): ForwardNavRepo idempotent upsert + guarded hypertable migration"
```

---

### Task 6: Gate logic

**Files:**
- Create: `src/fxhnt/application/gate.py`
- Test: `tests/unit/test_gate.py`

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

Create `tests/unit/test_gate.py`:

```python
"""Phase-1 go/no-go gate: WAIT until min_days, then GO if forward return+Sharpe clear the spec, else NO_GO."""
from __future__ import annotations

from fxhnt.application.forward_models import ForwardSummary
from fxhnt.application.gate import evaluate_gate

_SPEC = {"min_days": 20, "min_total_return": 0.0, "min_sharpe": 0.5}


def _sm(days, total_return, sharpe):
    return ForwardSummary("x", "2026-06-12", days, 1.0 + total_return, total_return, sharpe, -0.05)


def test_wait_before_min_days() -> None:
    assert evaluate_gate(_sm(7, 0.05, 3.0), _SPEC).status == "WAIT"


def test_go_when_thresholds_cleared() -> None:
    v = evaluate_gate(_sm(25, 0.04, 1.2), _SPEC)
    assert v.status == "GO"


def test_no_go_when_return_negative_after_window() -> None:
    v = evaluate_gate(_sm(25, -0.02, 1.2), _SPEC)
    assert v.status == "NO_GO" and "return" in v.reason.lower()


def test_no_go_when_sharpe_below_floor() -> None:
    v = evaluate_gate(_sm(25, 0.04, 0.1), _SPEC)
    assert v.status == "NO_GO" and "sharpe" in v.reason.lower()
```

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

Run: `pytest tests/unit/test_gate.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.application.gate'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/application/gate.py`:

```python
"""Phase-1 paper-forward gate. Pure function: given a ForwardSummary and the strategy's gate_spec, return a
verdict. It only reports — it never acts. WAIT until enough forward days; then GO if forward return and
Sharpe clear the spec; otherwise NO_GO (edge did not show up out-of-sample-in-time)."""
from __future__ import annotations

from fxhnt.application.forward_models import ForwardSummary, GateVerdict


def evaluate_gate(summary: ForwardSummary, gate_spec: dict) -> GateVerdict:
    min_days = int(gate_spec.get("min_days", 20))
    min_ret = float(gate_spec.get("min_total_return", 0.0))
    min_sharpe = float(gate_spec.get("min_sharpe", 0.0))

    if summary.days < min_days:
        return GateVerdict("WAIT", f"{summary.days}/{min_days} forward days")
    if summary.total_return <= min_ret:
        return GateVerdict("NO_GO", f"forward return {summary.total_return_pct:.2f}% <= floor")
    if summary.sharpe < min_sharpe:
        return GateVerdict("NO_GO", f"forward Sharpe {summary.sharpe:.2f} < {min_sharpe}")
    return GateVerdict("GO", f"{summary.days}d: +{summary.total_return_pct:.2f}%, Sharpe {summary.sharpe:.2f}")
```

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

Run: `pytest tests/unit/test_gate.py -v`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/gate.py tests/unit/test_gate.py
git commit -m "feat(cockpit): Phase-1 go/no-go gate (pure, reports-only)"
```

---

### Task 7: Forward ingest service

**Files:**
- Create: `src/fxhnt/application/forward_ingest.py`
- Test: `tests/integration/test_forward_ingest.py`

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

Create `tests/integration/test_forward_ingest.py`:

```python
"""Ingest walks the registry, reads each present state file, computes the gate, and upserts rows + summary.
A missing or malformed state file is skipped (logged), never aborts the batch."""
from __future__ import annotations

import datetime as dt
import json

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.forward_ingest import ForwardIngestService


def _setup(tmp_path):
    (tmp_path / "fxhnt_combined_forward.json").write_text(json.dumps({
        "inception": "2026-06-04", "last_date": "2026-06-06", "nav": 1.0201,
        "days": [{"date": "2026-06-05", "ret": 0.01, "nav": 1.01},
                 {"date": "2026-06-06", "ret": 0.0099, "nav": 1.0201}],
    }))
    (tmp_path / "multistrat_state.json").write_text(json.dumps({
        "last_date": "2026-06-12", "days": 5, "sum_r": -0.0007, "sumsq_r": 1.88e-05,
        "equity": 0.99926, "peak": 1.0014, "max_dd": -0.0046,
    }))
    repo = ForwardNavRepo("sqlite://")
    repo.migrate()
    return repo


def test_ingest_populates_summaries_and_rows(tmp_path) -> None:
    repo = _setup(tmp_path)
    svc = ForwardIngestService(repo, str(tmp_path))
    n = svc.ingest(at=dt.datetime(2026, 6, 14, 23, 30))
    assert n == 2                                              # two present trackers ingested
    summaries = {r.strategy_id: r for r in repo.all_summaries()}
    assert "combined" in summaries and "multistrat" in summaries
    assert summaries["combined"].gate_status == "WAIT"        # 2 < 20 days
    assert len(repo.nav_history("combined")) == 2


def test_ingest_skips_missing_files(tmp_path) -> None:
    repo = _setup(tmp_path)
    (tmp_path / "multistrat_state.json").unlink()             # now only combined present
    svc = ForwardIngestService(repo, str(tmp_path))
    n = svc.ingest(at=dt.datetime(2026, 6, 14, 23, 30))
    assert n == 1


def test_ingest_skips_malformed_file(tmp_path) -> None:
    repo = _setup(tmp_path)
    (tmp_path / "multistrat_state.json").write_text("{ not json")
    svc = ForwardIngestService(repo, str(tmp_path))
    n = svc.ingest(at=dt.datetime(2026, 6, 14, 23, 30))       # malformed skipped, combined still ingested
    assert n == 1
```

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

Run: `pytest tests/integration/test_forward_ingest.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.application.forward_ingest'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/application/forward_ingest.py`:

```python
"""The ETL seam: walk the strategy registry, read each present *_state.json via the state_reader, compute
the gate, and upsert (rows + summary) into the cockpit DB. Best-effort per tracker — one bad file is logged
and skipped, never aborting the batch (a single broken tracker must not blind the whole cockpit)."""
from __future__ import annotations

import datetime as dt
import logging
from pathlib import Path

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.persistence.state_reader import ForwardStateReader
from fxhnt.application.gate import evaluate_gate
from fxhnt.registry import STRATEGY_REGISTRY

_log = logging.getLogger(__name__)


class ForwardIngestService:
    def __init__(self, repo: ForwardNavRepo, state_dir: str,
                 reader: ForwardStateReader | None = None) -> None:
        self._repo = repo
        self._dir = Path(state_dir)
        self._reader = reader or ForwardStateReader()

    def ingest(self, at: dt.datetime) -> int:
        count = 0
        for sid, entry in STRATEGY_REGISTRY.items():
            path = self._dir / f"{entry['state_file']}.json"
            if not path.exists():
                continue
            try:
                summary, rows = self._reader.read(path, sid)
            except (ValueError, KeyError, OSError) as e:
                _log.warning("skipping tracker %s: %s", sid, e)
                continue
            verdict = evaluate_gate(summary, entry["gate_spec"])
            self._repo.upsert_rows(rows, at)
            self._repo.upsert_summary(summary, verdict.status, verdict.reason, at)
            count += 1
        return count
```

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

Run: `pytest tests/integration/test_forward_ingest.py -v`
Expected: PASS (3 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/forward_ingest.py tests/integration/test_forward_ingest.py
git commit -m "feat(cockpit): forward ingest ETL (registry-driven, best-effort)"
```

---

### Task 8: CLI — migrate-cockpit + ingest-forward

**Files:**
- Modify: `src/fxhnt/cli.py`

- [ ] **Step 1: Read the current CLI head for the typer pattern**

Run: `sed -n '1,25p' src/fxhnt/cli.py`
Expected: shows `app = typer.Typer(...)`, `from fxhnt.config import ... get_settings/Settings`, existing imports. Confirm the `app` and settings accessor names before editing.

- [ ] **Step 2: Add the two commands**

Append to `src/fxhnt/cli.py` (use the settings accessor the file already uses — `get_settings()`):

```python
@app.command("migrate-cockpit")
def migrate_cockpit() -> None:
    """Create the cockpit tables (+ TimescaleDB hypertable on Postgres) and seed the strategy registry."""
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    dsn = get_settings().operational_dsn
    ForwardNavRepo(dsn).migrate()
    typer.echo(f"cockpit migrated: {dsn.split('@')[-1]}")


@app.command("ingest-forward")
def ingest_forward(
    state_dir: str = typer.Option(..., "--state-dir", help="dir holding the *_state.json tracker files"),
) -> None:
    """Normalize every present tracker state file and upsert rows + summary into the cockpit DB."""
    import datetime as dt

    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.forward_ingest import ForwardIngestService
    repo = ForwardNavRepo(get_settings().operational_dsn)
    repo.migrate()
    n = ForwardIngestService(repo, state_dir).ingest(at=dt.datetime.now(dt.timezone.utc).replace(tzinfo=None))
    typer.echo(f"ingested {n} tracker(s) from {state_dir}")
```

- [ ] **Step 3: Smoke-test against a temp SQLite DB**

Run:
```bash
cp ~/Work/foxhunt/data/surfer/fxhnt_combined_forward.json /tmp/ck/fxhnt_combined_forward.json 2>/dev/null || (mkdir -p /tmp/ck && cp ~/Work/foxhunt/data/surfer/fxhnt_combined_forward.json /tmp/ck/)
FXHNT_OPERATIONAL_DSN='sqlite:////tmp/cockpit.db' fxhnt migrate-cockpit
FXHNT_OPERATIONAL_DSN='sqlite:////tmp/cockpit.db' fxhnt ingest-forward --state-dir /tmp/ck
```
Expected: `cockpit migrated: ...` then `ingested 1 tracker(s) from /tmp/ck`

- [ ] **Step 4: Commit**

```bash
git add src/fxhnt/cli.py
git commit -m "feat(cockpit): CLI migrate-cockpit + ingest-forward"
```

---

# Milestone 2 — Cockpit app

> **Added requirement (period-aware rolling mean, NO normalization):** the cockpit must also present
> **per-period data — daily / weekly / monthly — as RAW rolling means (no z-scoring, no vol-normalization)**.
> Task 9A below adds the pure aggregation module; Task 9 (service) surfaces it on the strategy detail;
> Task 11 (web) renders it. Keep it raw: bucket daily returns into calendar periods, compound within a
> bucket, report each period's raw return + a trailing rolling mean. Nothing is normalized.

### Task 9A: Period-aware aggregation + rolling mean (NO normalization)

**Files:**
- Create: `src/fxhnt/application/periods.py`
- Test: `tests/unit/test_periods.py`

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

Create `tests/unit/test_periods.py`:

```python
"""Period-aware aggregation: raw per-period compounded returns + trailing rolling mean — NO normalization."""
from __future__ import annotations

from fxhnt.application.periods import aggregate


def test_daily_passthrough_with_rolling_mean() -> None:
    pts = aggregate(["2026-06-05", "2026-06-06", "2026-06-07"], [0.01, 0.02, -0.01], "daily", window=2)
    assert [p.label for p in pts] == ["2026-06-05", "2026-06-06", "2026-06-07"]
    assert abs(pts[0].ret - 0.01) < 1e-12
    assert abs(pts[1].rolling_mean - (0.01 + 0.02) / 2) < 1e-12       # trailing 2
    assert abs(pts[2].rolling_mean - (0.02 - 0.01) / 2) < 1e-12


def test_weekly_buckets_compound_within_iso_week() -> None:
    # 2026-06-05 is Fri (prior ISO week); 2026-06-08 Mon + 2026-06-09 Tue share the next ISO week
    pts = aggregate(["2026-06-05", "2026-06-08", "2026-06-09"], [0.01, 0.02, 0.03], "weekly", window=4)
    assert len(pts) == 2
    assert all(p.label.startswith("2026-W") for p in pts)
    assert pts[0].label != pts[1].label
    assert abs(pts[0].ret - 0.01) < 1e-12
    assert abs(pts[1].ret - ((1.02 * 1.03) - 1.0)) < 1e-12           # compounded within the week, raw


def test_monthly_bucket_raw_no_normalization() -> None:
    pts = aggregate(["2026-05-30", "2026-06-01", "2026-06-15"], [0.01, 0.02, 0.03], "monthly", window=4)
    assert [p.label for p in pts] == ["2026-05", "2026-06"]
    assert abs(pts[1].ret - ((1.02 * 1.03) - 1.0)) < 1e-12          # raw compounded, not z-scored


def test_unknown_period_raises() -> None:
    try:
        aggregate(["2026-06-05"], [0.01], "yearly")
        assert False, "expected ValueError"
    except ValueError:
        pass
```

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

Run: `pytest tests/unit/test_periods.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.application.periods'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/application/periods.py`:

```python
"""Period-aware aggregation of a daily forward-return series. NO normalization — raw per-period returns and
a trailing rolling mean. Buckets daily returns into calendar periods (daily/weekly/monthly), compounding
within each bucket, then reports each period's raw return plus the trailing rolling mean over `window`
periods. The strategy detail view shows daily/weekly/monthly side by side so behavior is legible at each
granularity without any whitening."""
from __future__ import annotations

import datetime as dt
from dataclasses import dataclass

PERIODS = ("daily", "weekly", "monthly")


@dataclass(frozen=True, slots=True)
class PeriodPoint:
    label: str           # day "2026-06-08" | week "2026-W24" | month "2026-06"
    ret: float           # raw compounded return within the period (NOT normalized)
    rolling_mean: float  # trailing mean of per-period ret over the window (raw)


def _bucket(date: str, period: str) -> str:
    y, m, d = (int(x) for x in date.split("-"))
    if period == "daily":
        return date
    if period == "weekly":
        iso = dt.date(y, m, d).isocalendar()
        return f"{iso[0]}-W{iso[1]:02d}"
    if period == "monthly":
        return f"{y:04d}-{m:02d}"
    raise ValueError(f"unknown period {period!r}")


def aggregate(dates: list[str], rets: list[float], period: str, window: int = 4) -> list[PeriodPoint]:
    if period not in PERIODS:
        raise ValueError(f"period must be one of {PERIODS}")
    order: list[str] = []
    comp: dict[str, float] = {}
    for date, r in zip(dates, rets):
        key = _bucket(date, period)
        if key not in comp:
            comp[key] = 1.0
            order.append(key)
        comp[key] *= (1.0 + r)
    period_rets = [(k, comp[k] - 1.0) for k in order]
    out: list[PeriodPoint] = []
    for i, (label, pr) in enumerate(period_rets):
        lo = max(0, i - window + 1)
        win = [x for _, x in period_rets[lo:i + 1]]
        out.append(PeriodPoint(label=label, ret=pr, rolling_mean=sum(win) / len(win)))
    return out
```

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

Run: `pytest tests/unit/test_periods.py -v`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/periods.py tests/unit/test_periods.py
git commit -m "feat(cockpit): period-aware aggregation + rolling mean (raw, no normalization)"
```

### Task 9: Dashboard port + view-models + service

**Files:**
- Create: `src/fxhnt/ports/dashboard.py`
- Create: `src/fxhnt/application/dashboard_service.py`
- Test: `tests/integration/test_dashboard_service.py`

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

Create `tests/integration/test_dashboard_service.py`:

```python
"""DashboardService turns DB rows into view-models: a fleet overview (registry joined to summary) and a
per-strategy detail (summary + nav history)."""
from __future__ import annotations

import datetime as dt

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.dashboard_service import DashboardService
from fxhnt.application.forward_models import ForwardNavRow as Row
from fxhnt.application.forward_models import ForwardSummary


def _seeded() -> ForwardNavRepo:
    repo = ForwardNavRepo("sqlite://")
    repo.migrate()
    at = dt.datetime(2026, 6, 14, 23, 30)
    repo.upsert_rows([Row("combined", "2026-06-05", 0.01, 1.01),
                      Row("combined", "2026-06-06", 0.0099, 1.0201)], at)
    repo.upsert_summary(ForwardSummary("combined", "2026-06-06", 2, 1.0201, 0.0201, 1.2, -0.01),
                        "WAIT", "2/20 forward days", at)
    return repo


def test_fleet_joins_registry_to_summary() -> None:
    svc = DashboardService(_seeded())
    fleet = svc.fleet()
    combined = next(f for f in fleet if f.strategy_id == "combined")
    assert combined.display_name.startswith("Combined book")
    assert combined.days == 2 and combined.gate_status == "WAIT"
    assert round(combined.total_return_pct, 2) == 2.01


def test_fleet_includes_registry_entries_without_data() -> None:
    svc = DashboardService(_seeded())
    fleet = svc.fleet()
    funding = next(f for f in fleet if f.strategy_id == "funding")
    assert funding.days == 0 and funding.gate_status == "WAIT"   # no data yet -> shown, not errored


def test_detail_returns_summary_and_history() -> None:
    svc = DashboardService(_seeded())
    detail = svc.detail("combined")
    assert detail is not None
    assert detail.display_name.startswith("Combined book")
    assert [p.date for p in detail.history] == ["2026-06-05", "2026-06-06"]


def test_detail_includes_raw_period_aggregations() -> None:
    svc = DashboardService(_seeded())
    detail = svc.detail("combined")
    assert set(detail.periods) == {"daily", "weekly", "monthly"}
    assert [p.label for p in detail.periods["daily"]] == ["2026-06-05", "2026-06-06"]
    assert detail.periods["daily"][0].ret == 0.01          # raw, not normalized
    assert all(p.label.startswith("2026") for p in detail.periods["monthly"])


def test_detail_unknown_strategy_is_none() -> None:
    svc = DashboardService(_seeded())
    assert svc.detail("does-not-exist") is None
```

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

Run: `pytest tests/integration/test_dashboard_service.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.application.dashboard_service'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/ports/dashboard.py`:

```python
"""Dashboard read-model contract + view-models. The web adapter depends on this protocol, not on the
concrete service, so the rendering layer stays decoupled from persistence."""
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from fxhnt.application.forward_models import ForwardNavRow
from fxhnt.application.periods import PeriodPoint


@dataclass(frozen=True, slots=True)
class FleetRow:
    strategy_id: str
    display_name: str
    sleeve: str
    days: int
    total_return_pct: float
    sharpe: float
    maxdd: float
    gate_status: str
    gate_reason: str
    as_of: str


@dataclass(frozen=True, slots=True)
class StrategyDetail:
    strategy_id: str
    display_name: str
    sleeve: str
    venue: str
    days: int
    total_return_pct: float
    sharpe: float
    maxdd: float
    gate_status: str
    gate_reason: str
    as_of: str
    history: list[ForwardNavRow]
    periods: dict[str, list[PeriodPoint]]   # raw per-period rolling means: keys "daily","weekly","monthly"


class DashboardReadModel(Protocol):
    def fleet(self) -> list[FleetRow]: ...
    def detail(self, strategy_id: str) -> StrategyDetail | None: ...
```

Create `src/fxhnt/application/dashboard_service.py`:

```python
"""DashboardService — reads the cockpit DB into view-models. Every registry entry appears in the fleet even
with no data yet (shown as 0 days / WAIT), so the cockpit never hides a tracker just because its first
ingest has not landed."""
from __future__ import annotations

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.periods import PERIODS, aggregate
from fxhnt.ports.dashboard import FleetRow, StrategyDetail


class DashboardService:
    def __init__(self, repo: ForwardNavRepo) -> None:
        self._repo = repo

    def fleet(self) -> list[FleetRow]:
        summaries = {r.strategy_id: r for r in self._repo.all_summaries()}
        out: list[FleetRow] = []
        for reg in self._repo.registry():
            s = summaries.get(reg.strategy_id)
            out.append(FleetRow(
                strategy_id=reg.strategy_id, display_name=reg.display_name, sleeve=reg.sleeve,
                days=s.days if s else 0,
                total_return_pct=100.0 * s.total_return if s else 0.0,
                sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
                gate_status=s.gate_status if s else "WAIT",
                gate_reason=s.gate_reason if s else "no data yet",
                as_of=s.as_of if s else "",
            ))
        return out

    def detail(self, strategy_id: str) -> StrategyDetail | None:
        reg = {r.strategy_id: r for r in self._repo.registry()}.get(strategy_id)
        if reg is None:
            return None
        s = {r.strategy_id: r for r in self._repo.all_summaries()}.get(strategy_id)
        history = self._repo.nav_history(strategy_id)
        dates = [p.date for p in history]
        rets = [p.ret for p in history]
        periods = {p: aggregate(dates, rets, p) for p in PERIODS}   # raw daily/weekly/monthly, no normalization
        return StrategyDetail(
            strategy_id=strategy_id, display_name=reg.display_name, sleeve=reg.sleeve, venue=reg.venue,
            days=s.days if s else 0, total_return_pct=100.0 * s.total_return if s else 0.0,
            sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
            gate_status=s.gate_status if s else "WAIT",
            gate_reason=s.gate_reason if s else "no data yet", as_of=s.as_of if s else "",
            history=history, periods=periods,
        )
```

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

Run: `pytest tests/integration/test_dashboard_service.py -v`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/ports/dashboard.py src/fxhnt/application/dashboard_service.py tests/integration/test_dashboard_service.py
git commit -m "feat(cockpit): dashboard port + view-models + DashboardService"
```

---

### Task 10: Inline SVG sparkline chart

**Files:**
- Create: `src/fxhnt/adapters/web/__init__.py` (empty)
- Create: `src/fxhnt/adapters/web/charts.py`
- Test: `tests/unit/test_charts.py`

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

Create `tests/unit/test_charts.py`:

```python
"""nav_sparkline renders a self-contained inline SVG polyline from a NAV series (no JS, no external libs)."""
from __future__ import annotations

from fxhnt.adapters.web.charts import nav_sparkline


def test_sparkline_is_svg_with_points() -> None:
    svg = nav_sparkline([1.0, 1.01, 1.005, 1.02], width=100, height=20)
    assert svg.startswith("<svg") and svg.rstrip().endswith("</svg>")
    assert "<polyline" in svg and "points=" in svg


def test_sparkline_empty_series_is_safe() -> None:
    svg = nav_sparkline([], width=100, height=20)
    assert svg.startswith("<svg") and "polyline" not in svg


def test_sparkline_flat_series_does_not_divide_by_zero() -> None:
    svg = nav_sparkline([1.0, 1.0, 1.0], width=100, height=20)
    assert "<polyline" in svg            # flat line, no exception
```

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

Run: `pytest tests/unit/test_charts.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.adapters.web.charts'`

- [ ] **Step 3: Write minimal implementation**

Create `src/fxhnt/adapters/web/__init__.py` (empty file).

Create `src/fxhnt/adapters/web/charts.py`:

```python
"""Dependency-free inline-SVG charts. Server-rendered so the cockpit needs no JS charting library — the SVG
string is embedded directly in the HTML. nav_sparkline maps a NAV series to a polyline scaled to the box."""
from __future__ import annotations


def nav_sparkline(nav: list[float], *, width: int = 120, height: int = 28,
                  color: str = "#3fb950") -> str:
    box = f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '\
          'xmlns="http://www.w3.org/2000/svg">'
    if not nav:
        return box + "</svg>"
    lo, hi = min(nav), max(nav)
    span = (hi - lo) or 1.0
    n = len(nav)
    step = width / (n - 1) if n > 1 else 0.0
    pts = []
    for i, v in enumerate(nav):
        x = i * step
        y = height - (v - lo) / span * (height - 2) - 1     # 1px padding, y inverted
        pts.append(f"{x:.1f},{y:.1f}")
    stroke = color if nav[-1] >= nav[0] else "#f85149"
    return (box + f'<polyline fill="none" stroke="{stroke}" stroke-width="1.5" '
            f'points="{" ".join(pts)}" /></svg>')
```

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

Run: `pytest tests/unit/test_charts.py -v`
Expected: PASS (3 passed)

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/web/__init__.py src/fxhnt/adapters/web/charts.py tests/unit/test_charts.py
git commit -m "feat(cockpit): dependency-free inline-SVG nav sparkline"
```

---

### Task 11: FastAPI web app + templates + branding

**Files:**
- Create: `src/fxhnt/adapters/web/app.py`
- Create: `src/fxhnt/adapters/web/templates/base.html`, `cockpit.html`, `strategy.html`, `factory.html`
- Create: `src/fxhnt/adapters/web/static/fxhnt.png` (copied from foxhunt)
- Test: `tests/integration/test_web.py`

- [ ] **Step 1: Copy the logo and vendor htmx (no CDN dependency)**

Run:
```bash
mkdir -p src/fxhnt/adapters/web/static
cp ~/Work/foxhunt/fxhnt.png src/fxhnt/adapters/web/static/fxhnt.png
curl -fsSL https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js -o src/fxhnt/adapters/web/static/htmx.min.js
```
Expected: `fxhnt.png` and `htmx.min.js` exist under `static/`. Vendoring htmx (rather than a CDN `<script>`)
removes the Subresource-Integrity / CDN-compromise risk and lets the tailnet-private dashboard run without
external egress.

- [ ] **Step 2: Write the failing tests**

Create `tests/integration/test_web.py`:

```python
"""Web smoke: routes return 200 and render the seeded fleet + a strategy detail + healthz."""
from __future__ import annotations

import datetime as dt

from fastapi.testclient import TestClient

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.web.app import create_app
from fxhnt.application.forward_models import ForwardNavRow as Row
from fxhnt.application.forward_models import ForwardSummary


def _client() -> TestClient:
    repo = ForwardNavRepo("sqlite://")
    repo.migrate()
    at = dt.datetime(2026, 6, 14, 23, 30)
    repo.upsert_rows([Row("combined", "2026-06-05", 0.01, 1.01),
                      Row("combined", "2026-06-06", 0.0099, 1.0201)], at)
    repo.upsert_summary(ForwardSummary("combined", "2026-06-06", 2, 1.0201, 0.0201, 1.2, -0.01),
                        "WAIT", "2/20 forward days", at)
    return TestClient(create_app(repo))


def test_healthz() -> None:
    assert _client().get("/healthz").status_code == 200


def test_cockpit_lists_fleet() -> None:
    r = _client().get("/")
    assert r.status_code == 200
    assert "Combined book" in r.text and "WAIT" in r.text


def test_strategy_detail_renders() -> None:
    r = _client().get("/strategy/combined")
    assert r.status_code == 200 and "2026-06-06" in r.text
    assert "weekly" in r.text and "rolling mean" in r.text       # period-aware section present


def test_strategy_detail_unknown_404() -> None:
    assert _client().get("/strategy/nope").status_code == 404


def test_factory_route_ok() -> None:
    assert _client().get("/factory").status_code == 200
```

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

Run: `pytest tests/integration/test_web.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fxhnt.adapters.web.app'`

- [ ] **Step 4: Write the templates**

Create `src/fxhnt/adapters/web/templates/base.html`:

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>fxhnt cockpit</title>
  <script src="/static/htmx.min.js"></script>   <!-- vendored, no CDN: works on the tailnet w/o external egress -->

  <style>
    :root { color-scheme: dark; }
    body { background:#0d1117; color:#c9d1d9; font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; margin:0; }
    header { display:flex; align-items:center; gap:12px; padding:14px 20px; border-bottom:1px solid #21262d; }
    header img { height:28px; } header h1 { font-size:16px; margin:0; font-weight:600; }
    main { padding:20px; max-width:1100px; }
    table { border-collapse:collapse; width:100%; } th,td { text-align:right; padding:6px 10px; border-bottom:1px solid #21262d; }
    th:first-child, td:first-child { text-align:left; } a { color:#58a6ff; text-decoration:none; }
    .badge { padding:2px 8px; border-radius:10px; font-size:12px; }
    .WAIT { background:#21262d; color:#8b949e; } .GO { background:#193b1e; color:#3fb950; } .NO_GO { background:#3b1919; color:#f85149; }
    .pos { color:#3fb950; } .neg { color:#f85149; } .muted { color:#8b949e; }
  </style>
</head>
<body>
  <header><img src="/static/fxhnt.png" alt="fxhnt"><h1>{% block title %}cockpit{% endblock %}</h1>
    <span class="muted" style="margin-left:auto">{{ now }}</span></header>
  <main>{% block body %}{% endblock %}</main>
</body>
</html>
```

Create `src/fxhnt/adapters/web/templates/cockpit.html`:

```html
{% extends "base.html" %}
{% block title %}forward track record{% endblock %}
{% block body %}
<table hx-get="/" hx-trigger="every 60s" hx-select="table" hx-swap="outerHTML">
  <thead><tr><th>strategy</th><th>sleeve</th><th>days</th><th>ret%</th><th>Sharpe</th>
    <th>maxDD</th><th>nav</th><th>gate</th></tr></thead>
  <tbody>
  {% for f in fleet %}
    <tr>
      <td><a href="/strategy/{{ f.strategy_id }}">{{ f.display_name }}</a></td>
      <td class="muted">{{ f.sleeve }}</td>
      <td>{{ f.days }}</td>
      <td class="{{ 'pos' if f.total_return_pct >= 0 else 'neg' }}">{{ '%+.2f'|format(f.total_return_pct) }}</td>
      <td>{{ '%.2f'|format(f.sharpe) }}</td>
      <td class="neg">{{ '%.1f'|format(100*f.maxdd) }}%</td>
      <td>{{ spark(f.strategy_id)|safe }}</td>
      <td><span class="badge {{ f.gate_status }}">{{ f.gate_status }}</span></td>
    </tr>
  {% endfor %}
  </tbody>
</table>
<p class="muted">gate is a Phase-1 paper-forward verdict — it reports, it never acts. Short windows are noise.</p>
{% endblock %}
```

Create `src/fxhnt/adapters/web/templates/strategy.html`:

```html
{% extends "base.html" %}
{% block title %}{{ d.display_name }}{% endblock %}
{% block body %}
<p><a href="/">&larr; cockpit</a></p>
<p>sleeve <b>{{ d.sleeve }}</b> · venue <b>{{ d.venue }}</b> · as of <b>{{ d.as_of or '—' }}</b>
   · <span class="badge {{ d.gate_status }}">{{ d.gate_status }}</span> <span class="muted">{{ d.gate_reason }}</span></p>
<p>{{ d.days }} forward days ·
   <span class="{{ 'pos' if d.total_return_pct >= 0 else 'neg' }}">{{ '%+.2f'|format(d.total_return_pct) }}%</span>
   · Sharpe {{ '%.2f'|format(d.sharpe) }} · maxDD {{ '%.1f'|format(100*d.maxdd) }}%</p>
<div>{{ curve|safe }}</div>

<h3>per-period returns &amp; rolling mean <span class="muted">(raw — no normalization)</span></h3>
{% for period in ["daily", "weekly", "monthly"] %}
<details {% if period == "weekly" %}open{% endif %}>
  <summary>{{ period }}</summary>
  <table><thead><tr><th>period</th><th>ret%</th><th>rolling mean%</th></tr></thead><tbody>
  {% for p in d.periods[period] %}
    <tr><td>{{ p.label }}</td>
      <td class="{{ 'pos' if p.ret >= 0 else 'neg' }}">{{ '%+.3f'|format(100*p.ret) }}</td>
      <td class="{{ 'pos' if p.rolling_mean >= 0 else 'neg' }}">{{ '%+.3f'|format(100*p.rolling_mean) }}</td></tr>
  {% else %}
    <tr><td colspan="3" class="muted">no data yet</td></tr>
  {% endfor %}
  </tbody></table>
</details>
{% endfor %}

<h3>daily forward rows</h3>
<table><thead><tr><th>date</th><th>ret%</th><th>nav</th></tr></thead><tbody>
{% for p in d.history %}
  <tr><td>{{ p.date }}</td>
    <td class="{{ 'pos' if p.ret >= 0 else 'neg' }}">{{ '%+.3f'|format(100*p.ret) }}</td>
    <td>{{ '%.4f'|format(p.nav) }}</td></tr>
{% endfor %}
</tbody></table>
{% endblock %}
```

Create `src/fxhnt/adapters/web/templates/factory.html`:

```html
{% extends "base.html" %}
{% block title %}factory{% endblock %}
{% block body %}
<p class="muted">Strategy factory population. Global distinct hypotheses tested: <b>{{ total_trials }}</b></p>
<table><thead><tr><th>strategy_id</th><th>kind</th><th>status</th><th>OOS Sharpe</th></tr></thead><tbody>
{% for s in strategies %}
  <tr><td>{{ s.strategy_id }}</td><td class="muted">{{ s.strategy_kind }}</td>
    <td>{{ s.status }}</td><td>{{ '%.2f'|format(s.oos_sharpe) }}</td></tr>
{% else %}
  <tr><td colspan="4" class="muted">no factory strategies yet</td></tr>
{% endfor %}
</tbody></table>
{% endblock %}
```

- [ ] **Step 5: Write the app**

Create `src/fxhnt/adapters/web/app.py`:

```python
"""FastAPI composition root for the cockpit. Read-only, server-rendered (Jinja2 + HTMX). create_app(repo)
takes the repo so tests can inject a seeded in-memory DB; create_app() builds it from settings."""
from __future__ import annotations

import datetime as dt
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.web.charts import nav_sparkline
from fxhnt.application.dashboard_service import DashboardService

_HERE = Path(__file__).parent


def create_app(repo: ForwardNavRepo) -> FastAPI:
    app = FastAPI(title="fxhnt cockpit")
    app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static")
    templates = Jinja2Templates(directory=str(_HERE / "templates"))
    svc = DashboardService(repo)

    def _now() -> str:
        return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

    @app.get("/healthz")
    def healthz() -> dict:
        repo.all_summaries()                       # touches the DB; fails loudly if unreachable
        return {"status": "ok"}

    @app.get("/", response_class=HTMLResponse)
    def cockpit(request: Request) -> HTMLResponse:
        def spark(sid: str) -> str:
            return nav_sparkline([p.nav for p in repo.nav_history(sid)])
        return templates.TemplateResponse(request, "cockpit.html",
                                          {"fleet": svc.fleet(), "spark": spark, "now": _now()})

    @app.get("/strategy/{strategy_id}", response_class=HTMLResponse)
    def strategy(request: Request, strategy_id: str) -> HTMLResponse:
        d = svc.detail(strategy_id)
        if d is None:
            raise HTTPException(status_code=404, detail="unknown strategy")
        curve = nav_sparkline([p.nav for p in d.history], width=600, height=160)
        return templates.TemplateResponse(request, "strategy.html", {"d": d, "curve": curve, "now": _now()})

    @app.get("/factory", response_class=HTMLResponse)
    def factory(request: Request) -> HTMLResponse:
        strategies, total_trials = _factory_data()
        return templates.TemplateResponse(request, "factory.html",
                                          {"strategies": strategies, "total_trials": total_trials, "now": _now()})

    def _factory_data() -> tuple[list, int]:
        # The factory population lives in the existing operational store; read it best-effort so the cockpit
        # works even before the factory has run in this DB.
        try:
            from fxhnt.adapters.persistence.operational import SqlOperationalRepository
            from fxhnt.adapters.persistence.strategy_store import SqlStrategyStore
            from fxhnt.config import get_settings
            dsn = get_settings().operational_dsn
            runs = SqlOperationalRepository(dsn).list_runs(passed_only=True)
            trials = SqlStrategyStore(dsn).total_trials()
            return runs, trials
        except Exception:
            return [], 0

    return app


def create_app_from_settings() -> FastAPI:
    from fxhnt.config import get_settings
    repo = ForwardNavRepo(get_settings().operational_dsn)
    repo.migrate()
    return create_app(repo)
```

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

Run: `pytest tests/integration/test_web.py -v`
Expected: PASS (5 passed)

- [ ] **Step 7: Commit**

```bash
git add src/fxhnt/adapters/web/ tests/integration/test_web.py
git commit -m "feat(cockpit): FastAPI+HTMX web app, templates, branding, factory view"
```

---

### Task 12: CLI — serve

**Files:**
- Modify: `src/fxhnt/cli.py`

- [ ] **Step 1: Add the serve command**

Append to `src/fxhnt/cli.py`:

```python
@app.command()
def serve(
    host: str = typer.Option("0.0.0.0", "--host"),
    port: int = typer.Option(8080, "--port"),
) -> None:
    """Run the cockpit web app (uvicorn). Reads the operational DSN from settings."""
    import uvicorn

    from fxhnt.adapters.web.app import create_app_from_settings
    uvicorn.run(create_app_from_settings(), host=host, port=port)
```

- [ ] **Step 2: Smoke-test the app boots and serves**

Run:
```bash
FXHNT_OPERATIONAL_DSN='sqlite:////tmp/cockpit.db' fxhnt serve --port 8099 &
sleep 3 && curl -fsS localhost:8099/healthz && echo && curl -fsS localhost:8099/ | grep -o '<title>[^<]*' ; kill %1
```
Expected: `{"status":"ok"}` then `<title>fxhnt cockpit`

- [ ] **Step 3: Commit**

```bash
git add src/fxhnt/cli.py
git commit -m "feat(cockpit): CLI serve command (uvicorn)"
```

---

### Task 13: Full test sweep + lint

- [ ] **Step 1: Run the whole suite**

Run: `pytest -q`
Expected: all green (existing + new cockpit tests).

- [ ] **Step 2: Lint**

Run: `ruff check src/fxhnt/adapters/web src/fxhnt/application/forward_ingest.py src/fxhnt/application/dashboard_service.py src/fxhnt/application/gate.py src/fxhnt/adapters/persistence/state_reader.py src/fxhnt/adapters/persistence/forward_nav.py src/fxhnt/registry.py`
Expected: no errors (fix any import-order/unused warnings inline, re-run).

- [ ] **Step 3: Commit (if lint fixes were made)**

```bash
git add -A && git commit -m "chore(cockpit): lint fixes"
```

---

# Milestone 3 — In-cluster deploy

> Validation here is operational (build / apply / curl), not unit tests. Mirror the existing patterns in
> `infra/k8s/services/*.yaml` and `infra/k8s/gitlab/tailscale-proxy.yaml`. The in-cluster DSN is
> `postgresql+psycopg://<user>:<pass>@postgres.foxhunt.svc.cluster.local:5432/<db>` — read from the existing
> `foxhunt-secrets` (do NOT hardcode; confirm the exact key names with `kubectl -n foxhunt get secret foxhunt-secrets -o jsonpath='{.data}' | tr ',' '\n'`).

### Task 14: Dockerfile

**Files:**
- Create: `infra/docker/fxhnt.Dockerfile`
- Create: `infra/docker/.dockerignore`

- [ ] **Step 1: Write the Dockerfile**

Create `infra/docker/fxhnt.Dockerfile`:

```dockerfile
# fxhnt cockpit + pipeline image. One image serves the dashboard (fxhnt serve) and runs the daily
# fetch+track+ingest CronJob. Build context = the fxhnt repo root.
FROM python:3.11-slim AS base
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN pip install --upgrade pip && pip install '.[web,pg,data]'
# default DSN points at the in-cluster TimescaleDB; overridden by env in manifests
ENV FXHNT_OPERATIONAL_DSN="postgresql+psycopg://postgres:postgres@postgres.foxhunt.svc.cluster.local:5432/foxhunt"
EXPOSE 8080
CMD ["fxhnt", "serve", "--host", "0.0.0.0", "--port", "8080"]
```

Create `infra/docker/.dockerignore`:

```
**/__pycache__/
**/.pytest_cache/
.git/
tests/
docs/
*.egg-info/
```

- [ ] **Step 2: Build locally to verify it assembles**

Run: `docker build -f infra/docker/fxhnt.Dockerfile -t fxhnt-cockpit:dev .`
Expected: build succeeds; `docker run --rm fxhnt-cockpit:dev fxhnt --help` lists `serve`, `ingest-forward`, `migrate-cockpit`.

- [ ] **Step 3: Commit**

```bash
git add infra/docker/fxhnt.Dockerfile infra/docker/.dockerignore
git commit -m "build(cockpit): fxhnt Dockerfile (serve + pipeline image)"
```

---

### Task 15: CronJob — fetch + track + ingest

**Files:**
- Create: `infra/k8s/jobs/fxhnt-forward-cronjob.yaml`

> The fetch+track step reuses the existing foxhunt surfer scripts. For v1 the CronJob runs the ingest over a
> PVC that the trackers write to. If the trackers are not yet containerized, the CronJob can run ingest-only
> against a PVC synced from the laptop; wire the fetch/track containers in as they are containerized. Keep
> the schedule identical to the current laptop cron (23:30 UTC) for a clean cutover.

- [ ] **Step 1: Write the manifest**

Create `infra/k8s/jobs/fxhnt-forward-cronjob.yaml`:

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: fxhnt-surfer-data
  namespace: foxhunt
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 5Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: fxhnt-forward
  namespace: foxhunt
  labels: { app.kubernetes.io/name: fxhnt-forward, app.kubernetes.io/part-of: foxhunt }
spec:
  schedule: "30 23 * * *"          # daily 23:30 UTC — matches the retired laptop cron
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 1
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: ingest
              image: rg.fr-par.scw.cloud/foxhunt/fxhnt-cockpit:latest   # set by CI / Kaniko push
              command: ["sh", "-c"]
              args:
                - >
                  fxhnt migrate-cockpit &&
                  fxhnt ingest-forward --state-dir /data/surfer
              env:
                - name: FXHNT_OPERATIONAL_DSN
                  valueFrom: { secretKeyRef: { name: foxhunt-secrets, key: COCKPIT_DSN } }
                - name: DATABENTO_API_KEY
                  valueFrom: { secretKeyRef: { name: foxhunt-secrets, key: DATABENTO_API_KEY } }
              volumeMounts:
                - { name: surfer-data, mountPath: /data }
          volumes:
            - name: surfer-data
              persistentVolumeClaim: { claimName: fxhnt-surfer-data }
```

- [ ] **Step 2: Validate the manifest (dry-run)**

Run: `kubectl apply --dry-run=server -f infra/k8s/jobs/fxhnt-forward-cronjob.yaml`
Expected: `cronjob.batch/fxhnt-forward created (server dry run)` and `persistentvolumeclaim/fxhnt-surfer-data created (server dry run)`. If the secret key `COCKPIT_DSN` is missing, add it: `kubectl -n foxhunt patch secret foxhunt-secrets --type merge -p '{"stringData":{"COCKPIT_DSN":"postgresql+psycopg://<user>:<pass>@postgres.foxhunt.svc.cluster.local:5432/foxhunt"}}'` (use the real pg creds from the existing secret).

- [ ] **Step 3: Commit**

```bash
git add infra/k8s/jobs/fxhnt-forward-cronjob.yaml
git commit -m "deploy(cockpit): daily forward-ingest CronJob + data PVC"
```

---

### Task 16: Dashboard Deployment + Service + Tailscale proxy

**Files:**
- Create: `infra/k8s/services/fxhnt-dashboard.yaml`

- [ ] **Step 1: Read the existing tailscale-proxy pattern to mirror**

Run: `cat infra/k8s/gitlab/tailscale-proxy.yaml`
Expected: shows the ServiceAccount + Role(secrets) + RoleBinding + tailscale proxy Deployment pattern. Mirror its proxy container (`ghcr.io/tailscale/tailscale`), env (`TS_KUBE_SECRET`, `TS_USERSPACE`, `TS_AUTHKEY` from secret, `TS_DEST_IP` or `TS_HOSTNAME`) for the new hostname `dashboard`.

- [ ] **Step 2: Write the manifest**

Create `infra/k8s/services/fxhnt-dashboard.yaml`:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fxhnt-dashboard
  namespace: foxhunt
  labels: { app.kubernetes.io/name: fxhnt-dashboard, app.kubernetes.io/part-of: foxhunt }
spec:
  replicas: 1
  selector: { matchLabels: { app.kubernetes.io/name: fxhnt-dashboard } }
  template:
    metadata:
      labels: { app.kubernetes.io/name: fxhnt-dashboard }
    spec:
      containers:
        - name: cockpit
          image: rg.fr-par.scw.cloud/foxhunt/fxhnt-cockpit:latest
          command: ["fxhnt", "serve", "--host", "0.0.0.0", "--port", "8080"]
          ports: [{ containerPort: 8080 }]
          env:
            - name: FXHNT_OPERATIONAL_DSN
              valueFrom: { secretKeyRef: { name: foxhunt-secrets, key: COCKPIT_DSN } }
          readinessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 5, periodSeconds: 10 }
          livenessProbe:  { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10, periodSeconds: 20 }
          resources: { requests: { cpu: 50m, memory: 128Mi }, limits: { cpu: 500m, memory: 512Mi } }
---
apiVersion: v1
kind: Service
metadata:
  name: fxhnt-dashboard
  namespace: foxhunt
spec:
  selector: { app.kubernetes.io/name: fxhnt-dashboard }
  ports: [{ port: 80, targetPort: 8080 }]
---
# Per-service Tailscale proxy exposing the Service onto the tailnet as `dashboard` (-> dashboard.fxhnt.ai).
# Mirror infra/k8s/gitlab/tailscale-proxy.yaml exactly for ServiceAccount/Role/RoleBinding; only the
# hostname, TS_DEST_IP target Service, and names change. Filled per that file's structure:
apiVersion: v1
kind: ServiceAccount
metadata: { name: tailscale-dashboard, namespace: foxhunt }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: tailscale-dashboard, namespace: foxhunt }
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create", "get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: tailscale-dashboard, namespace: foxhunt }
subjects: [{ kind: ServiceAccount, name: tailscale-dashboard, namespace: foxhunt }]
roleRef: { kind: Role, name: tailscale-dashboard, apiGroup: rbac.authorization.k8s.io }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tailscale-dashboard-proxy
  namespace: foxhunt
  labels: { app.kubernetes.io/name: tailscale-dashboard-proxy, app.kubernetes.io/part-of: foxhunt }
spec:
  replicas: 1
  strategy: { type: Recreate }
  selector: { matchLabels: { app.kubernetes.io/name: tailscale-dashboard-proxy } }
  template:
    metadata:
      labels: { app.kubernetes.io/name: tailscale-dashboard-proxy }
    spec:
      serviceAccountName: tailscale-dashboard
      containers:
        - name: tailscale
          image: ghcr.io/tailscale/tailscale:latest
          env:
            - { name: TS_KUBE_SECRET, value: tailscale-dashboard-state }
            - { name: TS_USERSPACE, value: "true" }
            - { name: TS_HOSTNAME, value: dashboard }                       # -> dashboard.fxhnt.ai (MagicDNS)
            - { name: TS_DEST_IP, value: "" }                               # filled from the Service clusterIP, see note
            - name: TS_AUTHKEY
              valueFrom: { secretKeyRef: { name: foxhunt-secrets, key: TS_AUTHKEY } }
          securityContext: { runAsUser: 1000, runAsGroup: 1000 }
```

> Note for TS_DEST_IP: mirror exactly how `tailscale-proxy.yaml` targets its backend (some setups use
> `TS_DEST_IP` = the Service ClusterIP, others run the proxy as a sidecar or use the Tailscale k8s operator's
> `tailscale.com/expose` Service annotation). Prefer the SAME mechanism the working gitlab proxy uses. If the
> gitlab one uses a Service annotation instead of a standalone proxy Deployment, replace this proxy block
> with that annotation on the `fxhnt-dashboard` Service.

- [ ] **Step 3: Validate the manifest (dry-run)**

Run: `kubectl apply --dry-run=server -f infra/k8s/services/fxhnt-dashboard.yaml`
Expected: all five objects report `created (server dry run)` with no schema errors.

- [ ] **Step 4: Commit**

```bash
git add infra/k8s/services/fxhnt-dashboard.yaml
git commit -m "deploy(cockpit): dashboard Deployment + Service + Tailscale proxy (dashboard.fxhnt.ai)"
```

---

### Task 17: Build, push, deploy, verify (runbook)

> This task spends real resources (image push, cluster apply). Run interactively, confirm each step.

- [ ] **Step 1: Build + push the image (Kaniko or local docker push to the Scaleway registry)**

Run (local-docker path; mirror the existing CI/Kaniko if present):
```bash
docker build -f infra/docker/fxhnt.Dockerfile -t rg.fr-par.scw.cloud/foxhunt/fxhnt-cockpit:latest .
docker push rg.fr-par.scw.cloud/foxhunt/fxhnt-cockpit:latest
```
Expected: pushed digest. (Registry login via the existing Scaleway registry credentials.)

- [ ] **Step 2: Ensure the cockpit DSN secret exists**

Run: `kubectl -n foxhunt get secret foxhunt-secrets -o jsonpath='{.data.COCKPIT_DSN}' | wc -c`
Expected: non-zero. If zero, add it with the real pg creds (see Task 15 Step 2).

- [ ] **Step 3: Apply migration once, then the CronJob + dashboard**

Run:
```bash
kubectl apply -f infra/k8s/jobs/fxhnt-forward-cronjob.yaml
kubectl apply -f infra/k8s/services/fxhnt-dashboard.yaml
kubectl -n foxhunt create job --from=cronjob/fxhnt-forward fxhnt-forward-manual-001   # seed first ingest
kubectl -n foxhunt logs job/fxhnt-forward-manual-001 -f
```
Expected: `cockpit migrated: ...foxhunt` then `ingested N tracker(s)`.

- [ ] **Step 4: Verify the dashboard pod + reachability**

Run:
```bash
kubectl -n foxhunt rollout status deploy/fxhnt-dashboard --timeout=120s
kubectl -n foxhunt port-forward deploy/fxhnt-dashboard 8080:8080 &
sleep 3 && curl -fsS localhost:8080/healthz && curl -fsS localhost:8080/ | grep -o '<title>[^<]*'; kill %1
```
Expected: `{"status":"ok"}` and `<title>fxhnt cockpit`.

- [ ] **Step 5: Verify Tailscale exposure**

Run: `tailscale status | grep -i dashboard` then open `https://dashboard.fxhnt.ai` in a tailnet browser.
Expected: the proxy node appears; the cockpit loads over TLS on the tailnet. (Repoint `dashboard.fxhnt.ai` if it still maps to the old node.)

- [ ] **Step 6: Cutover — retire the laptop cron**

Run: `crontab -l | grep -v fxhnt_forward_cron.sh | crontab -`
Expected: the laptop cron line is removed; the pipeline now runs only in-cluster. Confirm the next day's CronJob populated fresh rows: `kubectl -n foxhunt logs job/<latest fxhnt-forward run>`.

- [ ] **Step 7: Commit any manifest tweaks made during rollout**

```bash
git add -A && git commit -m "deploy(cockpit): rollout fixes from first in-cluster deploy"
```

---

## Self-Review notes (addressed)

- **Spec coverage:** schema (Tasks 4-5) · state_reader 3 families (Task 3) · ingest SSOT (Task 7) · gate
  (Task 6) · cockpit/detail/factory views (Tasks 9, 11) · branding (Task 11) · CronJob/Deployment/proxy
  (Tasks 15-16) · cutover (Task 17 Step 6). All spec sections map to a task.
- **Snapshot-only honesty:** welford/funding families yield one forward point per ingest (Task 3) — the spec's
  "others build it forward" is implemented and tested.
- **Type consistency:** `ForwardSummary`/`ForwardNavRow`/`GateVerdict` (Task 2) used identically downstream;
  `ForwardNavRepo` methods (`migrate`, `upsert_rows`, `upsert_summary`, `nav_history`, `all_summaries`,
  `registry`) match across Tasks 5/7/9/11; view-models `FleetRow`/`StrategyDetail` consistent across Tasks 9/11.
- **No placeholders** in fxhnt code/tests. M3 infra has two explicitly-flagged confirmations (exact secret key
  names; the working tailscale-proxy mechanism) that MUST be read from the live cluster/existing manifest
  before apply — these are real-environment lookups, not invented values.
