# Declarative Strategy-Level Allocation Policy + Capital Ceiling (Sub-project B2a) — Design

**Date:** 2026-07-09
**Status:** Approved (brainstorming) — ready for implementation plan
**Sub-project:** B2a — the core of the allocation plane (layer 4, plane 2) of the "unified data-driven
pipeline" north-star.
**Builds on:** sub-project A (deterministic forward state — the DB record SSOT, merged) + B1 (declarative
gate + promotion policy, merged). B2a reads A's `forward_nav` records (per-strategy return series) and B1's
deploy-tier/promotion status.

## North-star context (layer 4, plane 2 of the allocation layer)

The allocation layer turns the set of {live + healthy} deployed strategies into target capital, driven by ONE
declarative, versioned, git-committed allocation policy, bounded by a HUMAN CAPITAL CEILING:

```
{live+healthy strategies}  (B1 deploy status; B3 health later — "healthy" defaults to all deploy-tier now)
  → STRATEGY-LEVEL allocation policy  → per-strategy target WEIGHT (ex-ante sized)   ← THIS sub-project (B2a)
  → each strategy's WITHIN-book weights → per-instrument weight  (already built; B2b later, maybe never)
  → × CAPITAL (≤ human ceiling)         → per-instrument target POSITIONS
  → rare aggregate KILLSWITCH (ex-ante) → (C) execution engine
```

Guiding principle (mirror of A/B1): allocation = `f(committed policy, A's records, B1's status)`. Policy
declarative + versioned + committed in git (a change is a reviewed commit + version bump). The human sets the
policy + a HARD CAPITAL CEILING (the one human gate) + arms venue keys. B2a produces the target weights +
target capital; it does NOT move capital (that is sub-project C — the execution engine). "Data-driven
mechanics, human-gated capital."

**The problem B2a solves.** Allocation knobs are scattered as function defaults (`book_allocator.combine_book`:
`target_vol=0.12`, `kelly_fraction=0.5`, `max_sleeve_weight=0.4`, `marginal_gate=False`; `_MAX_LEVERAGE=3.0`;
`apply_drawdown_killswitch`: `kill_dd=0.15`, `reenter_dd=0.07`) + config settings (`kelly_cap`, `max_gross`,
`combined_book_kill_dd`, `paper_capital`). There is no versioned allocation policy, no explicit capital
ceiling as a human gate, and `combine_book`'s leverage (`kelly · target_vol / trailing_port_vol`) is
**reactive de-sizing** — the approach rejected 3× (pearl_fxhnt_reactive_risk_controllers_rejected;
pearl_fxhnt_voltarget_overlay_is_reactive_desizing_rejected: pro-cyclical, cuts before the bounce). The
rejected-controllers conclusion is **ex-ante K + a rare killswitch, no reactive de-sizing**.

## Goal

A versioned in-code `AllocationPolicy` + a pure `compute_allocation` that turns the deploy-tier strategies
into ex-ante-sized per-strategy target weights (equal-weight the marginal-Sharpe-gate survivors; ex-ante
vol-target leverage from the **full-history** vol, never trailing), a rare latching aggregate killswitch, and
a `target_capital` that caps total deployed gross at a hard human `capital_ceiling`. The allocation is a
deterministic function of (policy + records + status), recomputable and audit-stamped with the policy version.

## Components

### 1. The allocation policy object (`allocation_policy.py`, in-code typed, versioned)

A new module `src/fxhnt/application/allocation_policy.py` (mirrors B1's `gate_policy.py`):

- `AllocationPolicy` — a frozen dataclass:
  - `target_vol: float = 0.12` — the ex-ante annualised vol target for the book.
  - `kelly_fraction: float = 0.5`, `max_leverage: float = 1.5` — fractional-Kelly + a hard leverage cap K.
  - `max_strategy_weight: float = 0.5` — per-strategy weight cap (before leverage).
  - `marginal_gate: bool = True` — the leave-one-out quality filter.
  - `min_history_days: int = 60` — a strategy needs at least this many forward-return days for a stable
    ex-ante vol estimate; below it, it is EXCLUDED (never sized on a short/noisy vol).
  - `kill_dd: float = 0.25`, `reenter_dd: float = 0.10` — the RARE aggregate killswitch thresholds (deep/high,
    latching; deliberately far above the day-to-day noise — a circuit-breaker, not a de-sizer).
  - `capital_ceiling: float = 35_000.0` — the HARD fund-level dollar cap on total deployed gross (the human
    gate). Initial value = the $35k pilot capital floor; the human raises it deliberately (a reviewed policy
    change + version bump) as the fund grows.
- `ALLOCATION_POLICY: AllocationPolicy` — the single current instance.
- `ALLOCATION_POLICY_VERSION: int = 1` — bumped on any policy change (auditable).
- `allocation_policy_hash(policy, version) -> str` — sha256 of the canonical fields + version (content-derived;
  a forgotten version bump still changes the hash → a self-check test fails, forcing the bump — same
  discipline as A's definition_hash and B1's policy_hash).

A capital-ceiling change is a policy change → a version bump → audited. The ceiling lives IN the versioned
policy.

### 2. The allocation engine (pure, ex-ante — `allocation_engine.py`)

`compute_allocation(returns_by_strategy: dict[str, dict[str, float]], policy: AllocationPolicy) -> dict[str, float]`
where `returns_by_strategy` is each live+healthy strategy's `{date: ret}` from A's record. Steps:

1. **Eligibility:** drop any strategy with fewer than `policy.min_history_days` return observations (err safe
   — excluded until it has a stable ex-ante vol). If none remain → return `{}` (deploy nothing).
2. **Marginal-Sharpe gate** (if `policy.marginal_gate`): leave-one-out over the equal-weight book — bench a
   strategy only when REMOVING it raises the equal-weight book's full-history Sharpe. Reuse
   `book_allocator._marginal_prune`'s logic adapted to equal weights. Survivors kept.
3. **Equal-weight** the survivors: `w_i = 1/N`, each capped at `policy.max_strategy_weight`, renormalised so
   the base weights sum to 1.
4. **Ex-ante leverage** `K = min(policy.max_leverage, policy.kelly_fraction · policy.target_vol /
   ex_ante_book_vol)`, where `ex_ante_book_vol` = the annualised realised vol of the equal-weight book's daily
   returns over the **FULL history** (all of A's record — stable, ex-ante), NOT a trailing window. Target
   weights = `K · w_i`. **A recent vol spike does not change K** (the anti-reactive invariant).
5. **Rare aggregate killswitch:** compute the equal-weight book's running drawdown over the full record; if it
   breaches `policy.kill_dd` and has not recovered inside `policy.reenter_dd` (latching), return all-zero
   weights (flatten). This is the ONLY drawdown-reactive element and is deliberately rare/latching.

Output: `{strategy_id: target_weight}` (ex-ante-sized, gated, killswitch-aware). Deterministic → recomputable.

### 3. Capital ceiling → target capital (`target_capital`)

`target_capital(target_weights: dict[str, float], equity: float, policy: AllocationPolicy) -> dict[str, float]`:

- `gross = sum(abs(w) for w in target_weights.values()) · equity` (the notional the weights imply at full
  equity), then `deployed = min(gross, policy.capital_ceiling)` — the **hard human gate: total deployed gross
  never exceeds `capital_ceiling`**, whatever the policy computes.
- Per-strategy dollars = `(abs(w_i) · equity / gross) · deployed` for each strategy (i.e. scale the notional
  down by `deployed/gross` when the ceiling binds; unchanged when it does not), carrying the sign of `w_i`.
- Edge cases: `gross == 0` (killswitch/empty) → all zero. `capital_ceiling >= gross` → no scaling.

### 4. Auditability + output

`compute_allocation`/`target_capital` are pure and deterministic from (policy + A's records + B1's status), so
the allocation is recomputable (no accumulated state — same principle as A). B2a records the latest
target-weight snapshot to a `strategy_allocation` table (`strategy_id, target_weight, target_dollars,
policy_version, policy_hash, as_of, computed_at`) — for the cockpit/audit and for sub-project C to consume.
A nightly asset (or a step in `cockpit_forward`) computes + records it after B1's ingest/promotion runs (so
the deploy-tier set is fresh). "Allocated under allocation-policy v2" is queryable.

### 5. Where the human stays in the loop

The human sets the `AllocationPolicy` (weighting/sizing/gate/killswitch thresholds + the `capital_ceiling`,
versioned in git — reviewed) and arms venue keys. B2a computes the target weights + target capital
automatically. NO capital is moved by B2a — it produces the TARGETS; moving capital is sub-project C
(execution) + manual arming. The capital ceiling is the hard human gate on how much can ever be deployed.

## Error handling

- **No eligible strategies** (none deploy-tier, or all below `min_history_days`) → empty allocation (deploy
  nothing). Safe.
- **Short/insufficient history** for a strategy → excluded (never sized on a noisy short-window vol, which
  would edge toward the reactive trap).
- **Killswitch latched** → all-zero weights (flatten) until recovery inside `reenter_dd`.
- **Capital ceiling binds** → scale the notional down so gross ≤ ceiling; never deploy more than the ceiling
  or more than equity.
- **NO reactive de-sizing:** leverage/sizing derive from the FULL-history vol (stable), never a trailing
  window. The only drawdown-reactive element is the rare, latching aggregate killswitch.

## Testing

- **Anti-reactive invariant (load-bearing):** appending a recent vol spike to a strategy's record does NOT
  change `K` (the ex-ante vol uses the full history) — the core guarantee that this is not the rejected
  reactive de-sizing.
- **Equal-weight + marginal gate:** a strategy whose removal raises the book Sharpe is benched; survivors are
  equal-weighted (capped + renormalised).
- **Ex-ante vol-target:** `K = min(max_leverage, kelly_fraction · target_vol / full_history_vol)`; the cap
  binds when the implied K exceeds `max_leverage`.
- **Capital ceiling:** total deployed gross ≤ `capital_ceiling` (binds when `equity·K > ceiling`), signs
  preserved; `ceiling >= gross` → unscaled.
- **Killswitch:** aggregate DD > `kill_dd` → all-zero (latching), recovery < `reenter_dd` → re-enter.
- **Eligibility:** a strategy with < `min_history_days` obs is excluded; no eligible strategies → `{}`.
- **Policy hash self-check** pins the current hash (a field change without a version bump fails it); the
  recorded snapshot carries `ALLOCATION_POLICY_VERSION` + hash.

## Out of scope (YAGNI)

No within-book allocation (B2b — each strategy keeps its own instrument weights). No execution / capital
movement (C). No health/decay (B3 — "healthy" is all deploy-tier for now; the killswitch is catastrophe
protection, NOT a decay detector — a quietly-dying single edge is B3's job, and must be ex-ante/structural,
not reactive). No inverse-vol reweighting (evidence: equal-weight already efficient). The rare killswitch is
the only drawdown-reactive element.

## Affected code (initial map — the plan refines this)

- New: `src/fxhnt/application/allocation_policy.py` — `AllocationPolicy`, `ALLOCATION_POLICY`,
  `ALLOCATION_POLICY_VERSION`, `allocation_policy_hash`.
- New: `src/fxhnt/application/allocation_engine.py` — `compute_allocation`, `target_capital`, the full-history
  ex-ante vol + equal-weight + marginal-gate + killswitch composition (reusing `book_allocator._marginal_prune`
  / `apply_drawdown_killswitch` where they fit an EX-ANTE, full-history use — NOT `combine_book`'s reactive
  trailing-vol leverage).
- New: `strategy_allocation` table (`cockpit_models.py`) + repo write/read (`forward_nav.py`).
- Modify: a nightly asset / `cockpit_forward` step computes + records the allocation after B1's promotion runs.
- Reuse: `ForwardNavRepo.nav_history` (per-strategy returns), the deploy-tier set (registry `tier=="deploy"` ∪
  `promoted_strategy_ids()`), `book_allocator` marginal-gate + killswitch helpers.
```
