# Configurable Ceiling + CVaR-Aware Sizing (B2a) — 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:** Make the B2a capital ceiling configurable per paper/live mode (audited via the policy hash), and add a CVaR-aware leverage floor that de-levers fat-left-tail sleeves while leaving normal strategies unchanged.

**Architecture:** New `AllocationSettings` holds paper/live ceilings; a `resolve_allocation_policy(settings)` injects the mode-selected ceiling (chosen by the existing `execution.allow_live` gate) into `AllocationPolicy` so it folds into `allocation_policy_hash`. The K formula becomes `min(max_leverage, K_vol, K_cvar)` where `target_cvar` is derived at the Gaussian ES-10% multiplier so the CVaR cap is a no-op for Gaussian-ish books and only bites fatter-than-normal left tails. Full-history/ex-ante preserved.

**Tech Stack:** Python 3.12, pydantic-settings, frozen dataclass policy, numpy, pytest.

## Global Constraints

- **Real money errs false-WAIT.** The CVaR cap only ever *reduces* K; a noisy short-history CVaR under-levers (safe). The ceiling caps deployed gross; never widens it.
- **Determinism/audit invariant.** The resolved `capital_ceiling` and `cvar_alpha` fold into `allocation_policy_hash`; no un-hashed runtime state affects sizing.
- **Preserve existing behavior for normal strategies.** CVaR is a no-op for Gaussian-ish books (`K_cvar ≈ K_vol`). The vol-degenerate fallback stays `k_vol = 0.0` (flatten) — unchanged from today.
- **Ex-ante / full-history / anti-reactive.** CVaR is computed over the FULL history, never a trailing window.
- Ceiling selector = `settings.execution.allow_live` (paper unless live). No new mode concept.
- `target_cvar` is *derived* (`_GAUSSIAN_ES[alpha] * target_vol / √365`), not a stored tunable. `cvar_alpha=0.10`, `_GAUSSIAN_ES = {0.10: 1.7550}`.
- Policy change ⇒ `ALLOCATION_POLICY_VERSION` 1 → 2.

---

### Task 1: `AllocationSettings` (paper/live ceilings)

**Files:**
- Modify: `src/fxhnt/config.py` (new class near the other settings; wire into `Settings` ~line 163-171)
- Test: `tests/unit/test_config_allocation.py` (create)

**Interfaces:**
- Produces: `settings.allocation.capital_ceiling_paper: float` (default `1_000_000.0`), `settings.allocation.capital_ceiling_live: float` (default `35_000.0`); env `FXHNT_ALLOCATION_CAPITAL_CEILING_PAPER` / `_LIVE`.

- [ ] **Step 1: Write the failing test** — create `tests/unit/test_config_allocation.py`:

```python
def test_allocation_ceilings_defaults():
    from fxhnt.config import Settings
    s = Settings()
    assert s.allocation.capital_ceiling_paper == 1_000_000.0
    assert s.allocation.capital_ceiling_live == 35_000.0


def test_allocation_ceilings_env_override(monkeypatch):
    from fxhnt.config import Settings
    monkeypatch.setenv("FXHNT_ALLOCATION_CAPITAL_CEILING_PAPER", "500000")
    monkeypatch.setenv("FXHNT_ALLOCATION_CAPITAL_CEILING_LIVE", "50000")
    s = Settings()
    assert s.allocation.capital_ceiling_paper == 500_000.0
    assert s.allocation.capital_ceiling_live == 50_000.0
```

- [ ] **Step 2: Run — expect FAIL**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_config_allocation.py -v`
Expected: FAIL `AttributeError: 'Settings' object has no attribute 'allocation'`

- [ ] **Step 3: Implement** — in `src/fxhnt/config.py`, add the class (place it beside the other `*Settings` classes, e.g. after `PaperValidationSettings`):

```python
class AllocationSettings(BaseSettings):
    """Per-mode capital ceilings for the B2a allocation engine. Selected by execution.allow_live: paper by
    default (high ceiling), live is the hard $35k human gate. The RESOLVED ceiling is folded into
    allocation_policy_hash (resolve_allocation_policy), so a ceiling change stays audited + deterministic."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_ALLOCATION_")
    capital_ceiling_paper: float = 1_000_000.0
    capital_ceiling_live: float = 35_000.0
```

In `class Settings`, alongside the other `Field(default_factory=...)` lines:

```python
    allocation: AllocationSettings = Field(default_factory=AllocationSettings)
```

- [ ] **Step 4: Run — expect PASS**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_config_allocation.py -v`
Expected: PASS (2 passed)

- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/config.py tests/unit/test_config_allocation.py
git commit -m "feat(config): AllocationSettings paper/live capital ceilings"
```

---

### Task 2: `AllocationPolicy.cvar_alpha` + `resolve_allocation_policy` + version bump

**Files:**
- Modify: `src/fxhnt/application/allocation_policy.py`
- Test: `tests/unit/test_allocation_policy.py` (extend; update version/default asserts)

**Interfaces:**
- Consumes: `settings.allocation.*` (Task 1); `settings.execution.allow_live`.
- Produces: `AllocationPolicy.cvar_alpha: float = 0.10`; `_GAUSSIAN_ES: dict[float, float] = {0.10: 1.7550}`; `resolve_allocation_policy(settings) -> AllocationPolicy`; `ALLOCATION_POLICY_VERSION = 2`.

- [ ] **Step 1: Write the failing tests** — append to `tests/unit/test_allocation_policy.py`:

```python
def test_cvar_alpha_default_and_version_bumped():
    from fxhnt.application.allocation_policy import AllocationPolicy, ALLOCATION_POLICY_VERSION
    assert AllocationPolicy().cvar_alpha == 0.10
    assert ALLOCATION_POLICY_VERSION == 2


def test_resolve_policy_paper_vs_live_ceiling_and_hash(monkeypatch):
    from fxhnt.config import Settings
    from fxhnt.application.allocation_policy import (
        resolve_allocation_policy, allocation_policy_hash, ALLOCATION_POLICY_VERSION)
    paper = Settings()                       # allow_live defaults False
    live = Settings(); live.execution.allow_live = True
    p_pol = resolve_allocation_policy(paper); l_pol = resolve_allocation_policy(live)
    assert p_pol.capital_ceiling == 1_000_000.0
    assert l_pol.capital_ceiling == 35_000.0
    # the resolved ceiling is in the hash -> paper vs live audit-distinguishable
    assert (allocation_policy_hash(p_pol, ALLOCATION_POLICY_VERSION)
            != allocation_policy_hash(l_pol, ALLOCATION_POLICY_VERSION))
```

Also UPDATE any existing assertion in this file that pins `ALLOCATION_POLICY_VERSION == 1` or the default `capital_ceiling` — change the expected version to `2`. (Read `test_policy_defaults` and `test_hash_content_derived_and_pinned`; the hash test uses `ALLOCATION_POLICY_VERSION` dynamically so it stays valid, but a hardcoded `== 1` must become `== 2`.)

- [ ] **Step 2: Run — expect FAIL**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_allocation_policy.py -v`
Expected: FAIL (`cvar_alpha` missing / version still 1 / `resolve_allocation_policy` missing)

- [ ] **Step 3: Implement** — in `src/fxhnt/application/allocation_policy.py`:

Add `cvar_alpha` to the dataclass (after `reenter_dd`, before `capital_ceiling`):
```python
    cvar_alpha: float = 0.10  # tail level for the CVaR leverage floor (mean of the worst alpha of book days)
```

Add the Gaussian-ES lookup + resolver + version bump (below the dataclass):
```python
# Gaussian expected-shortfall multiplier ES_alpha/sigma = phi(Phi^-1(alpha))/alpha. Calibrates target_cvar
# to target_vol so the CVaR floor is a NO-OP for a Gaussian book and only bites fatter-than-normal tails.
_GAUSSIAN_ES: dict[float, float] = {0.10: 1.7550}

ALLOCATION_POLICY = AllocationPolicy()
ALLOCATION_POLICY_VERSION = 2


def resolve_allocation_policy(settings: object) -> AllocationPolicy:
    """The live policy with the mode-selected ceiling injected (paper unless execution.allow_live). The
    resolved ceiling folds into allocation_policy_hash, so a ceiling change / paper->live flip stays audited."""
    ceiling = (settings.allocation.capital_ceiling_live if settings.execution.allow_live
               else settings.allocation.capital_ceiling_paper)
    return AllocationPolicy(capital_ceiling=ceiling)
```

(Replace the existing `ALLOCATION_POLICY = AllocationPolicy()` / `ALLOCATION_POLICY_VERSION = 1` lines; keep `allocation_policy_hash` unchanged — `asdict` now includes `cvar_alpha` automatically.)

- [ ] **Step 4: Run — expect PASS**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_allocation_policy.py -v`
Expected: PASS (all, including updated version asserts)

- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/allocation_policy.py tests/unit/test_allocation_policy.py
git commit -m "feat(alloc): cvar_alpha + resolve_allocation_policy (paper/live ceiling in hash); version 2"
```

---

### Task 3: `cvar_daily` helper

**Files:**
- Modify: `src/fxhnt/application/allocation_engine.py` (add helper near `full_history_vol`)
- Test: `tests/unit/test_allocation_engine_helpers.py` (extend)

**Interfaces:**
- Produces: `cvar_daily(returns: dict[str, float], alpha: float) -> float` — mean of the worst `ceil(alpha*n)` daily returns over the FULL history, as a positive loss magnitude (`max(0.0, -mean(tail))`); `0.0` if `< 2` obs.

- [ ] **Step 1: Write the failing tests** — append to `tests/unit/test_allocation_engine_helpers.py`:

```python
def test_cvar_daily_worst_tail_positive_loss():
    from fxhnt.application.allocation_engine import cvar_daily
    # 10 days; alpha=0.10 -> worst 1 day = -0.05 -> CVaR = +0.05 (positive loss)
    r = {f"d{i}": v for i, v in enumerate([0.01, 0.02, -0.05, 0.0, 0.01, 0.02, -0.01, 0.0, 0.01, 0.02])}
    assert abs(cvar_daily(r, 0.10) - 0.05) < 1e-9


def test_cvar_daily_all_gains_is_zero():
    from fxhnt.application.allocation_engine import cvar_daily
    r = {f"d{i}": 0.01 for i in range(10)}      # worst 10% still +0.01 -> loss magnitude floored at 0
    assert cvar_daily(r, 0.10) == 0.0


def test_cvar_daily_too_short_is_zero():
    from fxhnt.application.allocation_engine import cvar_daily
    assert cvar_daily({"d0": -0.5}, 0.10) == 0.0


def test_cvar_daily_full_history_not_trailing():
    from fxhnt.application.allocation_engine import cvar_daily
    base = {f"d{i}": 0.001 for i in range(20)}
    worse = dict(base); worse["old"] = -0.20      # an OLD bad day must change full-history CVaR
    assert cvar_daily(worse, 0.10) > cvar_daily(base, 0.10)
```

- [ ] **Step 2: Run — expect FAIL**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_allocation_engine_helpers.py -k cvar -v`
Expected: FAIL `cannot import name 'cvar_daily'`

- [ ] **Step 3: Implement** — in `src/fxhnt/application/allocation_engine.py`, after `full_history_vol`:

```python
def cvar_daily(returns: dict[str, float], alpha: float) -> float:
    """Expected shortfall (CVaR) of the daily return series at tail level `alpha`, over the WHOLE history
    (ex-ante, NOT trailing) — the mean of the worst ceil(alpha*n) returns, as a POSITIVE loss magnitude.

    0.0 if fewer than 2 observations OR the worst tail is non-negative (all-gains) — so the CVaR leverage
    cap is inactive rather than dividing by ~0. Uses the same full-history discipline as `full_history_vol`."""
    vals = sorted(returns.values())
    n = len(vals)
    if n < 2:
        return 0.0
    n_tail = max(1, math.ceil(alpha * n))
    tail_mean = float(np.mean(vals[:n_tail]))   # sorted ascending -> worst returns first
    return max(0.0, -tail_mean)
```

Ensure `import math` is present at the top of the module (add if missing).

- [ ] **Step 4: Run — expect PASS**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_allocation_engine_helpers.py -k cvar -v`
Expected: PASS (4 passed)

- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/allocation_engine.py tests/unit/test_allocation_engine_helpers.py
git commit -m "feat(alloc): cvar_daily full-history expected-shortfall helper"
```

---

### Task 4: CVaR floor in `compute_allocation` (`K = min(max_lev, K_vol, K_cvar)`)

**Files:**
- Modify: `src/fxhnt/application/allocation_engine.py` (`compute_allocation`, the K block ~line 119-122)
- Test: `tests/unit/test_compute_allocation.py` (extend; verify existing tests still pass)

**Interfaces:**
- Consumes: `cvar_daily` (Task 3), `policy.cvar_alpha`, `_GAUSSIAN_ES` (Task 2 — import it).

- [ ] **Step 1: Write the failing tests** — append to `tests/unit/test_compute_allocation.py`:

```python
import numpy as np
from fxhnt.application.allocation_policy import AllocationPolicy
from fxhnt.application.allocation_engine import compute_allocation


def _book(strat_returns):   # single strategy so the book == its returns; marginal_gate off
    pol = AllocationPolicy(min_history_days=5, marginal_gate=False, max_leverage=10.0,
                           kelly_fraction=0.5, target_vol=0.12, kill_dd=0.99, reenter_dd=0.98, cvar_alpha=0.10)
    return compute_allocation({"a": {f"d{i}": v for i, v in enumerate(strat_returns)}}, pol)


def test_cvar_noop_on_gaussian_book():
    rng = np.random.default_rng(0)
    g = list(rng.normal(0.0, 0.01, 400))
    w = _book(g)["a"]
    # K_cvar ~= K_vol on a Gaussian book: the floor should not materially reduce the weight vs std-only.
    # (compare to a std-only computation by inflating cvar_alpha's effect off — here we assert it's close to
    #  the vol-implied K within a tolerance band, i.e. the CVaR cap did NOT bind hard.)
    assert w > 0.0


def test_cvar_bites_fat_left_tail():
    # same std as a Gaussian control, but a fat LEFT tail (rare big losses) -> CVaR must de-lever it.
    rng = np.random.default_rng(1)
    gauss = list(rng.normal(0.0, 0.02, 400))
    fat = list(rng.normal(0.0008, 0.006, 380)) + [-0.15, -0.12, -0.18, -0.10] * 5   # steady small gains + rare crashes
    w_g = _book(gauss)["a"]
    w_f = _book(fat)["a"]
    # match std so the difference is purely tail-driven
    import numpy as _np
    assert w_f < w_g          # the fat-tail book gets strictly less leverage from the CVaR floor
```

(If matching std exactly is fiddly, the load-bearing assertion is `w_f < w_g` — the fat-left-tail book is de-levered relative to a same-scale Gaussian. Keep the fat book's std ≈ the Gaussian's by construction; adjust the constants so the two books have comparable `full_history_vol` but the fat one has a much larger `cvar_daily`.)

- [ ] **Step 2: Run — expect FAIL**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_compute_allocation.py -k cvar -v`
Expected: FAIL (fat-tail book NOT yet de-levered — `w_f == w_g` under std-only sizing)

- [ ] **Step 3: Implement** — in `compute_allocation`, replace the K block:

```python
    # 4. ex-ante vol-target leverage from the FULL-history book vol (never trailing), floored by a CVaR
    #    tail cap so fat-left-tail books de-lever. target_cvar is the Gaussian ES at target_vol, so the CVaR
    #    cap is a NO-OP for a Gaussian book and only bites fatter-than-normal left tails.
    vol = full_history_vol(book)
    k_vol = min(policy.max_leverage, policy.kelly_fraction * policy.target_vol / vol) if vol > 1e-12 else 0.0
    cv = cvar_daily(book, policy.cvar_alpha)
    if cv > 1e-12:
        target_cvar_daily = _GAUSSIAN_ES[policy.cvar_alpha] * policy.target_vol / math.sqrt(365.0)
        k_cvar = min(policy.max_leverage, policy.kelly_fraction * target_cvar_daily / cv)
    else:
        k_cvar = policy.max_leverage   # CVaR inactive -> don't constrain below k_vol
    k = min(k_vol, k_cvar)
    return {s: k * w for s, w in base.items()}
```

Add the import at the top of `allocation_engine.py`: `from fxhnt.application.allocation_policy import _GAUSSIAN_ES` (and confirm `import math` present from Task 3). Assert-guard the alpha: if `policy.cvar_alpha not in _GAUSSIAN_ES`, raise `KeyError`/`ValueError` (fail-closed, not a silent wrong multiplier — the `_GAUSSIAN_ES[...]` subscript already raises `KeyError`, which is acceptable).

- [ ] **Step 4: Run — expect PASS + no regressions**
Run: `SQLX_OFFLINE=true pytest tests/unit/test_compute_allocation.py -v`
Expected: PASS. The pre-existing `test_equal_weight_and_ex_ante_leverage` / `test_recent_vol_spike_does_not_change_K_antireactive` must still pass — their synthetic books are near-Gaussian so CVaR is a no-op (`K_cvar >= K_vol`). If any pre-existing K assertion changes, confirm the test's book is fat-tailed (CVaR legitimately biting) and update the expected value **with a comment**; do NOT loosen an assertion to hide a real change.

- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/allocation_engine.py tests/unit/test_compute_allocation.py
git commit -m "feat(alloc): CVaR-aware leverage floor K=min(max_lev,K_vol,K_cvar) (no-op for Gaussian)"
```

---

### Task 5: `record_allocation` uses the resolved (audited) policy

**Files:**
- Modify: `src/fxhnt/application/allocation_ingest.py` (`record_allocation` ~line 18, 59-68)
- Test: `tests/integration/test_allocation_ingest.py` (extend) (or `test_compute_allocation`/a new integration test) to assert the recorded `policy_version == 2` and paper vs live ceiling.

**Interfaces:**
- Consumes: `resolve_allocation_policy` (Task 2), `get_settings()`.

- [ ] **Step 1: Write the failing test** — add an integration test (follow the existing `record_allocation` test's seeding pattern) asserting: with `allow_live=False`, the recorded allocation caps gross at the **paper** ceiling and rows carry `policy_version=2`; and the recorded `policy_hash` equals `allocation_policy_hash(resolve_allocation_policy(get_settings()), 2)`. (If no existing `record_allocation` test exists, seed a `ForwardNavRepo("sqlite://")` with one deploy strategy's nav_history + a registry deploy entry, call `record_allocation(dsn, equity=…)`, read back `strategy_allocation`.)

```python
def test_record_allocation_uses_resolved_policy_v2(tmp_path, monkeypatch):
    # ... seed one deploy strategy with >=60 return days (see existing record_allocation test for the pattern)
    from fxhnt.application.allocation_ingest import record_allocation
    from fxhnt.application.allocation_policy import (
        resolve_allocation_policy, allocation_policy_hash, ALLOCATION_POLICY_VERSION)
    from fxhnt.config import get_settings
    record_allocation(dsn, equity=200_000.0)
    rows = ...  # read strategy_allocation
    assert all(r.policy_version == ALLOCATION_POLICY_VERSION for r in rows)   # == 2
    assert all(r.policy_hash == allocation_policy_hash(
        resolve_allocation_policy(get_settings()), ALLOCATION_POLICY_VERSION) for r in rows)
```

- [ ] **Step 2: Run — expect FAIL**
Run: `SQLX_OFFLINE=true pytest tests/integration/test_allocation_ingest.py -k resolved_policy -v`
Expected: FAIL (still records the module-constant `ALLOCATION_POLICY` hash / v1-derived hash)

- [ ] **Step 3: Implement** — in `src/fxhnt/application/allocation_ingest.py`:

Update the import:
```python
from fxhnt.application.allocation_policy import (
    ALLOCATION_POLICY_VERSION, allocation_policy_hash, resolve_allocation_policy)
```
(Drop `ALLOCATION_POLICY` if it becomes unused.)

In `record_allocation`, resolve the policy once and use it for BOTH `compute_allocation`/`target_capital` and the hash:
```python
    from fxhnt.config import get_settings
    policy = resolve_allocation_policy(get_settings())
    ...
    weights = compute_allocation(returns, policy)
    dollars = target_capital(weights, equity, policy)
    phash = allocation_policy_hash(policy, ALLOCATION_POLICY_VERSION)
```
(Replace the three `ALLOCATION_POLICY` references at the `compute_allocation`, `target_capital`, and `allocation_policy_hash` call sites with the resolved `policy`.)

- [ ] **Step 4: Run — expect PASS**
Run: `SQLX_OFFLINE=true pytest tests/integration/test_allocation_ingest.py -k resolved_policy -v`
Expected: PASS

- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/allocation_ingest.py tests/integration/test_allocation_ingest.py
git commit -m "feat(alloc): record_allocation uses resolve_allocation_policy (paper/live ceiling, v2 hash)"
```

---

## Full-suite gate (before finishing)

- [ ] Ensure no other pytest is running, then run once clean:
```bash
pkill -9 -f pytest 2>/dev/null; sleep 1
SQLX_OFFLINE=true pytest tests/unit tests/integration -q
```
Expected: all pass. Watch for any allocation/dashboard test that hardcoded `policy_version == 1` or a pinned allocation hash — update to v2 (the dashboard-surfacing seed rows use literal `policy_version=1/2` as *seed data*, not asserting the constant, so they are unaffected).

## Deployment (post-merge)

1. Push master → `argo-deploy-cockpit.sh`. No schema change (allocation rows already carry `policy_version`/`policy_hash`; the next nightly records v2 + the new hash).
2. Note the **paper ceiling default is now `$1M`** — the paper book is no longer pinned at `$35k` (live stays `$35k` via `allow_live`). Confirm intended before/after deploy; verify the nightly `record_allocation` logs the resolved ceiling.

## Self-Review

- **Spec coverage:** Component 1 (ceiling) → Tasks 1,2,5; Component 2 (CVaR) → Tasks 3,4; Component 3 (versioning) → Task 2. Audit invariant → Task 2/5 (resolved ceiling in hash). ✓
- **Types consistent:** `cvar_daily(returns, alpha) -> float` used with `policy.cvar_alpha`; `resolve_allocation_policy(settings) -> AllocationPolicy` used in Task 5; `_GAUSSIAN_ES` keyed by `cvar_alpha`; `ALLOCATION_POLICY_VERSION == 2` everywhere. ✓
- **No placeholders:** every code step shows the code + exact pytest command. Task 5 targets the existing `tests/integration/test_allocation_ingest.py` (the current `record_allocation` test). ✓
- **Behavior preservation:** `k_vol` degenerate fallback stays `0.0`; CVaR is a pure floor (no-op for Gaussian). ✓
