# Configurable Capital Ceiling + CVaR-Aware Ex-Ante Sizing (B2a)

**Date:** 2026-07-11
**Status:** Approved (design)
**Depends on:** B2a allocation engine (merged). Real-money-adjacent — sizes real capital.

## Problem

Two gaps in the B2a allocation engine (`allocation_policy.py` + `allocation_engine.py`):

1. **The hard `$35k` capital ceiling is a hardcoded frozen-dataclass default.** It cannot be set for
   different modes (e.g. run paper at a high ceiling while live stays hard-gated at `$35k`) without a code
   edit. The earlier `$1M`-paper experiment had to override `strategy_allocation` directly in Postgres — a
   hack the ceiling design should make unnecessary. But the ceiling is *deliberately* part of the versioned,
   content-hashed policy (`allocation_policy_hash`) so "a ceiling change is audited"; any fix must stay
   inside that audit discipline (a raw un-hashed env override would break the deterministic-forward-state
   invariant).

2. **The ex-ante vol-target sizes by standard deviation only.** `full_history_vol` = annualised std. For
   most strategies std ≈ risk, but a fat-left-tail short-vol sleeve (the planned Alpaca defined-risk VRP
   sleeve) has risk std systematically *understates* — it looks low-vol right up until it spikes. Std-based
   vol-targeting would over-allocate such a sleeve. Sizing needs a tail-aware component.

## Goals

- Make the capital ceiling **configurable per mode (paper vs live)** while keeping it **inside the audited
  policy hash** (a change still versions + audits deterministically).
- Add a **CVaR-aware leverage floor** so fat-left-tail sleeves are de-levered by tail risk, while normal
  (Gaussian-ish) strategies are sized exactly as today.
- Preserve the load-bearing **ex-ante / full-history / anti-reactive** property (CVaR over full history,
  never a trailing window; no reactive de-sizing).

## Non-Goals / Hard Constraints

- **Real money errs false-WAIT.** The CVaR floor only ever *reduces* leverage; a noisy short-history CVaR
  therefore errs toward under-leverage (safe). The ceiling caps deployed gross; never widens it.
- **Determinism/audit invariant holds.** The resolved ceiling + `cvar_alpha` fold into
  `allocation_policy_hash`; no un-hashed runtime state affects sizing.
- No reactive de-sizing. Sizing derives from full-history statistics; the only drawdown-reactive element
  remains the existing rare aggregate killswitch (unchanged).

## Design

### Component 1 — Paper/live capital ceiling (audit-preserving)

**Settings (new)** in `config.py` — a small `AllocationSettings` (env prefix `FXHNT_ALLOCATION_`):
```python
class AllocationSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="FXHNT_ALLOCATION_")
    capital_ceiling_paper: float = 1_000_000.0   # FXHNT_ALLOCATION_CAPITAL_CEILING_PAPER
    capital_ceiling_live:  float = 35_000.0       # FXHNT_ALLOCATION_CAPITAL_CEILING_LIVE
```
Exposed as `settings.allocation`.

**Selector = the existing `execution.allow_live` gate** (no new mode concept):
```
ceiling = settings.allocation.capital_ceiling_live if settings.execution.allow_live
          else settings.allocation.capital_ceiling_paper
```
Paper by default (`allow_live=False`) → high ceiling; flipping the fund live → the `$35k` hard gate. Reuses
the gate the system already has for real-money execution, so paper/live ceiling and paper/live execution
are governed by one switch.

**Resolution + audit.** A new builder resolves the policy from settings and injects the ceiling:
```python
def resolve_allocation_policy(settings) -> AllocationPolicy:
    ceiling = (settings.allocation.capital_ceiling_live if settings.execution.allow_live
               else settings.allocation.capital_ceiling_paper)
    return AllocationPolicy(capital_ceiling=ceiling)   # other fields = fixed policy defaults
```
`allocation_policy_hash(resolved_policy, VERSION)` is computed on the **resolved** policy → the effective
ceiling is in the hash → a ceiling change (or paper→live flip) versions + audits deterministically.
`record_allocation` calls `resolve_allocation_policy(get_settings())` instead of using the module constant
`ALLOCATION_POLICY` for the ceiling-bearing path. (`ALLOCATION_POLICY = AllocationPolicy()` may remain for
tests/back-compat, but the live caller uses the resolved policy.)

### Component 2 — CVaR-aware ex-ante sizing (tail-aware leverage floor)

The K formula becomes a **min over three caps** — size by whichever binds tightest:
```
K_vol  = kelly_fraction * target_vol / full_history_vol(book)          # unchanged (annualised std, √365)
K_cvar = kelly_fraction * target_cvar_daily / cvar_daily(book, alpha)  # NEW tail-aware floor
K      = min(max_leverage, K_vol, K_cvar)
```

**Gaussian-calibrated `target_cvar` (the no-op-for-normal property):**
- CVaR-α of a standard normal = `φ(Φ⁻¹(α)) / α`. For α=0.10 this is `0.1755/0.10 = 1.7550` (call it `M`).
- `target_cvar_daily = M * (target_vol / sqrt(365.0))` — the daily expected-shortfall a Gaussian book at
  `target_vol` would have (√365 to match `full_history_vol`'s `_ANN` convention).
- For a Gaussian-ish book, `cvar_daily(book) ≈ M * σ_daily = M * full_history_vol(book)/√365`, so
  `K_cvar ≈ K_vol` — **the floor is a no-op**. `K_cvar < K_vol` only when the book's left tail is fatter
  than Gaussian (a VRP short-vol blowup). `target_cvar` is **derived, not a new tunable** — matches
  adaptive-not-tuned + max-with-floor.

**`cvar_daily(book, alpha)`** — a new pure helper in `allocation_engine.py`:
- `book` = the **aggregate deploy book** daily returns (`deploy_book_returns`, the same equal-weight series
  the std vol-target + killswitch already use). Aggregate, not per-strategy: VRP's fat tail shows up in the
  aggregate (diversified with the rest), so the whole book de-levers when the tail fattens.
- CVaR = the mean of the worst `ceil(alpha * n)` returns over the **FULL history** (ex-ante, never
  trailing), returned as a **positive loss magnitude** (`-mean(worst tail)`, floored at 0). Needs
  `n_tail = max(1, ceil(alpha*n))` observations; with `min_history_days=60` and α=0.10 that is ~6 obs.
- Degenerate cases: `< 2` obs → return 0.0 (so `K_cvar` is unconstrained, mirroring `full_history_vol`'s
  `< 2 → 0.0` → the guard `if cvar > 1e-12 else skip that cap`). A book with a non-negative worst-tail (all
  gains in-sample) → CVaR ≤ 0 → skip the CVaR cap (don't divide by ~0; K_vol governs).

**Integration in `compute_allocation`** (after the killswitch flatten check, alongside the existing vol
block):
```python
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   # PRESERVED: vol-degenerate → flatten
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 (no in-sample tail loss / <2 obs) → don't constrain below k_vol
k = min(k_vol, k_cvar)
return {s: k * w for s, w in base.items()}
```
`_GAUSSIAN_ES = {0.10: 1.7550}` (a small lookup keyed by the supported alpha; assert the policy's alpha is a
key). **The `k_vol` degenerate fallback stays `0.0`** (unchanged from today — vol-degenerate flattens). The
CVaR cap is a *pure floor*: when `cv` is ~0 the cap is inactive (`k_cvar = max_leverage`, so `k = k_vol`),
never forcing `k` to 0 on its own. When `vol==0`, `k_vol=0 → k=0` regardless of CVaR (today's behavior).

### Component 3 — Policy versioning

- `AllocationPolicy` gains `cvar_alpha: float = 0.10`. `capital_ceiling` keeps its `35_000.0` default (for
  the bare `ALLOCATION_POLICY` constant / tests) but is injected by `resolve_allocation_policy` for the live
  path.
- New field ⇒ **`ALLOCATION_POLICY_VERSION` 1 → 2**. The content-derived `allocation_policy_hash` self-check
  forces the bump (any allocation recorded under v1 vs v2 is distinguishable + audited).
- `target_cvar` is derived at compute time (not stored) → not a separate hashed tunable; `cvar_alpha` and
  the resolved `capital_ceiling` ARE in the hash.

## Data Flow

```
cockpit_forward (nightly) → record_allocation(dsn, equity)
  → policy = resolve_allocation_policy(get_settings())      # ceiling: paper(1M) unless allow_live → live(35k)
  → returns = {funded deploy sid: daily returns}            # unchanged
  → weights = compute_allocation(returns, policy)           # eligibility→marginal→equal→killswitch→K
        K = min(max_leverage, K_vol, K_cvar)                # CVaR floor de-levers only fat-tail books
  → dollars = target_capital(weights, equity, policy)       # gross capped at policy.capital_ceiling
  → replace_allocations(...) ; hash = allocation_policy_hash(policy, 2)   # resolved ceiling in the hash
```

## Error Handling

- CVaR with `< 2` obs or non-negative worst-tail → CVaR skipped (K_vol governs); never a divide-by-zero.
- Unsupported `cvar_alpha` (not in `_GAUSSIAN_ES`) → explicit error at compute (fail-closed, not a silent
  wrong multiplier).
- Ceiling: `target_capital` already clamps `deployed = min(gross, ceiling)`; a `0`/non-finite ceiling → the
  existing `gross <= 0 → all-zero` guard applies (no capital deployed = safe).
- Determinism: the resolved ceiling is in the hash; two runs with the same settings + returns → identical
  weights + hash.

## Testing

Unit (`tests/unit/`):
- `resolve_allocation_policy`: `allow_live=False` → paper ceiling; `allow_live=True` → live ceiling; the two
  produce **different `allocation_policy_hash`** (audit).
- `cvar_daily`: worst-10% mean over full history; positive-loss sign; `<2` obs → 0.0; all-gains tail → ≤0
  (skipped); full-history not trailing (adding an old bad day changes it).
- `compute_allocation` CVaR no-op: a Gaussian synthetic book → `K_cvar ≈ K_vol` (weights within tolerance of
  the std-only result) — normal strategies unchanged.
- `compute_allocation` CVaR bites: a fat-left-tail book (steady small gains + rare large losses, same std as
  a Gaussian control) → `K_cvar < K_vol` → strictly lower weights than the std-only result.
- `min(k_vol, k_cvar)` picks the tightest; `vol==0 → k_vol=0 → k=0` (unchanged); `cv~0 → k_cvar` inactive → `k=k_vol`.
- Version: `ALLOCATION_POLICY_VERSION == 2`; hash changes vs a v1 policy.

Existing B2a tests: must still pass (normal strategies' K unchanged because CVaR is a no-op for them);
update any test asserting `ALLOCATION_POLICY_VERSION == 1` or the exact hash.

## File Touchpoints

| File | Change |
|---|---|
| `src/fxhnt/config.py` | `AllocationSettings` (paper/live ceilings) + `settings.allocation` |
| `src/fxhnt/application/allocation_policy.py` | `AllocationPolicy.cvar_alpha`; `resolve_allocation_policy(settings)`; `ALLOCATION_POLICY_VERSION` 1→2; `_GAUSSIAN_ES` |
| `src/fxhnt/application/allocation_engine.py` | `cvar_daily(book, alpha)`; `compute_allocation` K = min(max_lev, K_vol, K_cvar) |
| `src/fxhnt/application/allocation_ingest.py` | `record_allocation` uses `resolve_allocation_policy(get_settings())` for the ceiling-bearing policy + hash |
| `tests/unit/test_allocation_engine.py` (+ policy/config tests) | CVaR + ceiling + version tests; update v1/hash asserts |

## Deploy Notes

- No schema change (allocation rows already carry `policy_version` + `policy_hash`; they'll record v2 + the
  new hash on the next nightly).
- Behavior change is intentional and bounded: normal deploy strategies size identically (CVaR no-op); a
  future VRP sleeve gets de-levered by the CVaR floor. Paper ceiling default `1M` means the paper book is no
  longer pinned at `$35k` — verify that's intended for the paper track before deploy (live stays `35k` via
  `allow_live`).
