# IBKR Consolidation — OPRA-backed XSP-VRP sleeve + UCITS-mapped multistrat routing

**Date:** 2026-07-12
**Status:** Approved (design)
**Supersedes:** `docs/superpowers/specs/2026-07-11-vrp-modeled-sleeve-design.md` (the synthetic
flat-VIX Black–Scholes VRP approach — parked because the synthetic was a biased, un-validatable
placeholder; this spec rebuilds VRP on **real OPRA option prices**).
**Depends on:** the forward engine (`run_track`, recompute/observed), the reconciliation gate (B1),
the CVaR-aware allocation engine (merged `edeb40b`), the multistrat forward-track + exec-twin pattern
(C1/C2/C3), the Databento adapter (`src/fxhnt/adapters/data/databento.py`).

## Problem / Goal

Consolidate the fund's *tradfi* execution onto **IBKR** (EUR-native IBKR Ireland, the venue the operator
already trusts), dropping Alpaca entirely. Crypto stays structurally on **Bybit** — our crypto edges are
perpetual-futures-based (funding harvest, broad-alt cross-sectional momentum, unlock shorts, the Bybit
4-edge book) and IBKR offers only spot crypto (~10 coins, no funding, no shorting), so *zero* crypto
strategies can run there. The fund is inherently two venues: IBKR (tradfi) + Bybit (crypto).

Two forces shape the tradfi side:

1. **PRIIPs** blocks a Dutch *retail* IBKR account from buying US-domiciled ETFs (SPY/IEF/GLD/PDBC/DBMF —
   no EU KID). The multistrat book must *execute* on **UCITS-equivalent** ETFs. But EU-venue price history
   is short (Databento `EPRL.DOM` Euronext starts 2023-03-28) — so we **model on the US underlyings**
   (long clean history) and **execute on UCITS**, measuring the divergence with an exec twin.
2. **OPRA** (verified empirically: `OPRA.PILLAR` carries real XSP *and* SPX option bars back to
   **2013-04-01**, `ohlcv-1d`, ~$0.88/mo for XSP) **un-parks the equity-VRP sleeve**. VRP was shelved
   because we had no real option data; now we can build a *faithful* defined-risk XSP put-credit-spread
   backtest on real prices — true credits, real skew, through COVID + 2022. **XSP** (cash-settled 1/10-SPX,
   European exercise, Section-1256, EU-retail-available on IBKR) is cleaner than the parked Alpaca-SPY plan
   (no early assignment, no pin risk).

**Goal:** one fused spec, one venue-consolidation goal, **two bounded components**, execution **coded but
shipped SUSPENDED** throughout (neither XSP options nor UCITS-Euronext can be paper-executed on the current
US-style `DU9600528` account):

- **Component A (centerpiece):** a `vrp` strategy producing a recomputable daily defined-risk XSP
  put-credit-spread return series from **real OPRA data**, wired into the forward engine + reconciliation
  gate + CVaR-aware allocation + cockpit. Execution twin (`execute-vrp`, IBKR XSP `mleg` orders) coded,
  suspended.
- **Component B (thin):** UCITS-legal *execution routing* for the existing multistrat book — the modeled
  track is unchanged (still models US underlyings); B adds a US→UCITS instrument map + an `--venue ucits`
  execute path, coded and suspended.

## Non-Goals / Hard Constraints

- **No live execution this round.** Both exec legs ship SUSPENDED (the C1/C2/C3 pattern). Arming is a later
  operational step once an IBKR account with the right permissions (options; Euronext) exists.
- **No change to the multistrat modeled track.** `multistrat_nav` keeps modeling US underlyings. B is
  execution routing only. The currently-armed US-ETF IBKR-paper track (`multistrat_exec`, DU9600528, $1M)
  is left running untouched — `--venue ucits` is an additive *second* mode, default stays `us`.
- **No change to the crypto book / Bybit.** Out of scope by nature.
- **Defined-risk ONLY** for VRP — the modeled series is a *put-credit-spread* with bounded max loss
  (width − credit), never a naked put-write. Load-bearing safety property: std under-sizes short-vol's fat
  tail, so the sleeve is safe only because (a) the CVaR floor de-levers it and (b) defined-risk bounds max
  loss.
- **Determinism spine:** state is a deterministic function of committed policy + observed data. OPRA bars
  are **frozen into a PIT warehouse table** and recomputed off the frozen rows — never re-hitting OPRA
  live (determinism + respects the `$0.50` cost-guard). Mirrors the equity PIT-snapshot discipline.
- **Real money errs false-WAIT:** recompute-from-frozen-PIT; reconciliation gate before any capital;
  `promote_on_pass=False` for `vrp` (deliberate human promote); sized small via the existing engine; VRP is
  **NOT a systemic hedge** (co-crashes systemic risk-off — it diversifies *idiosyncratic* crypto crashes).
- **Verify the cockpit LOCALLY** (read the NAV/gate in a real browser) per
  `feedback_verify_cockpit_locally_not_prod`.
- Every review Minor is a latent bug — fix in-loop.

## Architecture

```
Component A — VRP sleeve (NEW alpha, the centerpiece)
  OPRA.PILLAR (real XSP option chains, 2013→)
    → xsp_option_bars PIT warehouse table (one-time backfill + nightly append; deterministic)
    → VrpStrategy.advance(): put-call-parity forward + IV-inversion 20Δ strike + real-mark defined-risk P&L
    → run_track("vrp") → forward + reconciliation gate + CVaR sizing → cockpit
    → [SUSPENDED] execute-vrp: IBKR XSP mleg put-credit-spread orders + vrp_exec twin

Component B — UCITS multistrat routing (PRIIPs-legal live)
  Modeled track UNCHANGED (multistrat_nav models US underlyings — long clean history)
    → UCITS instrument map (US ticker → Euronext UCITS ticker), config
    → [SUSPENDED] execute-multistrat --venue ucits: IBKR whole-share UCITS routing + ibkr-ucits exec twin
```

The two components are asymmetric on purpose: A is a genuinely new options-data pipeline feeding a
newly-validatable strategy; B is thin because the multistrat modeled track already models US underlyings
correctly, so B is only the execution *routing*. The plan builds A's substance first, then B's routing.

## Component A — VRP on real OPRA data

### A.1 — Data path (`databento.py` options extension + PIT warehouse)

Extend `DatabentoDataProvider` with an options path on `OPRA.PILLAR`, used two ways:

- **Chain discovery (entry days):** `definition` schema via **parent** symbology (`XSP.OPT`) → all listed
  contracts (expiries, strikes, put/call) active on a date. Filter to the target ~30-DTE expiry and puts.
- **Per-contract marks (daily):** `ohlcv-1d` by **raw OSI symbol** (e.g. `XSP   YYMMDDP00470000`) for the
  specific held contracts.

The current `_resolve()` raises `NotImplementedError` for options; add an `AssetClass.OPTION` branch and two
new methods: `fetch_option_chain(underlying_parent, as_of) -> list[OptionContract]` and
`fetch_option_bars(osi_symbols, start, end) -> dict[osi, PriceSeries]`. Both honor the existing
`get_cost` guard; the one-time 2013→ backfill runs in a Job under a raised cap, chunked by month.

**PIT freeze:** a new warehouse table `xsp_option_bars(date, osi_symbol, expiry, strike, right, open, high,
low, close)` (+ a small `xsp_option_bars` repo with `upsert_bars`, `bars_for(osi_symbols, start, end)`,
`chain_asof(date, dte_window)`). One-time backfill for the ref; nightly append for the forward. The strategy
recomputes off **frozen rows**, never re-hitting OPRA live.

### A.2 — Underlying forward + IV inversion (self-contained, OPRA-internal)

No SPX/XSP index level exists in any Databento dataset (OPRA is options-only), and SPY as a proxy injects
dividend/tracking drift into the inversion. Instead derive the **implied forward from put-call parity** on
the ATM pair: `F = K_atm + e^{rT}(C_atm − P_atm)` (also yields the implied rate `r`). Then per put, invert
Black–Scholes on the real OPRA close (Newton, seeded from a Brenner–Subrahmanyam ATM guess, bracketed
fallback) → implied vol → BS put delta `Δ = −N(−d1)` under forward `F`. A degenerate day (no arb-free
solution, `τ≤0`, or a crossed/zero mark) → that contract is skipped; if the ladder can't be marked that day,
the day books flat (track warms, never crashes).

### A.3 — Strike selection & the strategy (`src/fxhnt/application/vrp_book.py`)

Pure, testable BS/IV helpers + `VrpStrategy` implementing `advance(last_date, extra) -> (list[(iso_date,
fractional_return)], extra)` (recompute mode). **Rules (approved):**

- **Weekly** entry (fixed weekday).
- **~30 DTE** XSP expiry (nearest listed).
- Short leg = strike nearest **|Δ| = 0.20** (from A.2 inversion); long leg a fixed **5-XSP-point width**
  below. Per-spread `margin = 5 × 100 = $500`; max loss = `(5 − credit) × 100`.
- Held as a **ladder of ~4–5** staggered concurrent spreads (weekly entries) → always-on smooth daily
  series.
- **Daily mark:** re-price each open spread on real OPRA closes (A.1); unrealized P&L = `credit − current
  spread value`.
- **Management:** close (realize) at **50% profit** OR **1 trading day before expiry**. XSP is cash-settled
  European → no early assignment / no pin risk; close-early only avoids settlement-day noise.
- **Return basis (defined-risk):** daily fractional return = `Σ(daily MTM change over open spreads) /
  (N_open × margin)` — return per dollar of defined-risk margin. Fixed nominal capital = `N × margin`; no
  leverage inside the sleeve (the allocation engine sizes it).

The same `VrpStrategy` recomputes the **full 2013→ backtest ref** (one-time, off frozen OPRA) and the recent
**forward window** (nightly) — mirroring how `MultiStratStrategy` produces both.

### A.4 — Forward-track, gate, sizing (rides merged infra)

- **`vrp_nav` Dagster asset** (`assets.py`, mirrors `multistrat_nav`): append today's OPRA bars to the PIT
  table → `run_track(repo, "vrp", VrpStrategy(...), today, at)` → `persist_backtest_ref=True` (publishes the
  basis-matched real ref via the one-time full-history `VrpStrategy().advance(None, {})`).
- **`definitions.py`:** add `vrp_nav` to `combined_book_forward_job.selection` + `Definitions.assets`; add it
  to `cockpit_forward`'s deps.
- **`registry.py`:** `"vrp"` entry — `display_name` "Equity VRP (XSP put-credit-spreads, defined-risk)",
  `sleeve` "equity-vol", `venue` **"ibkr-xsp"**, `tier` "research", `execution` "paper", `state_file`
  "vrp_state", `record_mode` "recompute", `gate_spec = {gate_type: "reconciliation", min_forward_days: 21}`,
  **`promote_on_pass` False** (real-capital-adjacent → deliberate human promote after watching it
  reconcile).
- **`migration_builders.py`:** `builders["vrp"] = ("vrp_state", _build_vrp)` so `migrate-forward-anchors`
  seeds the recompute anchor (the CRITICAL-1 recompute-track pattern).
- **Sizing:** no new sizing code. Once promoted, `record_allocation → compute_allocation` sizes `vrp`; the
  **CVaR floor (merged `edeb40b`) de-levers the fat-left tail** — now genuinely fat because the ref carries
  real COVID/2022 crash marks, not a Gaussian synthetic. Defined-risk bounds its contribution to the
  aggregate killswitch drawdown.

### A.5 — Execution twin (coded, SUSPENDED)

- **`IbkrBroker` options path** (new): construct an atomic `mleg` combo (BAG) — sell the 20Δ short put, buy
  the wing — with a negative limit price = the target net credit. Requires IBKR options-trading permission.
- **`execute-vrp` CLI:** fetch the live XSP chain, select the 20Δ/5-pt spread, place the `mleg` order,
  record executed fills; venue recorded `ibkr-xsp`. Guards mirror `execute-multistrat` (paper-envelope
  refuse on non-paper, per-order gap rows, kill-switch, idempotency marker).
- **`fxhnt-vrp-rebalancer` CronJob** (weekly), `suspend: true` — arm only once the account has options
  permission.
- Records a **`vrp_exec` twin** reconciling executed-vs-modeled slippage — surfaced in the cockpit
  "Executed reality" block, exactly like the multistrat/bybit twins.

## Component B — UCITS multistrat routing (thin)

### B.1 — UCITS instrument map (`config.py`)

A typed `US ticker → Euronext-UCITS ticker` map (env-overridable `FXHNT_UCITS_MAP`), defaults refinable when
the account exists:

| Sleeve | Modeled (US, unchanged) | Executed (UCITS, suspended) |
|---|---|---|
| S&P 500 | SPY | CSPX (fallback VUAA) |
| 7–10y UST | IEF | IBTM (resolved on Euronext `EPRL.DOM`) |
| Gold | GLD | SGLN (fallback IGLN) |
| Broad commodities | PDBC | ICOM-type commodity UCITS |
| Managed futures | DBMF | DBMF.L (iMGP DBi Managed Futures UCITS) |

The map is **execution-only**. The modeled series always uses US underlyings; the map only rewrites which
symbols the (suspended) execute path sends. This keeps determinism clean — modeled = US, executed = UCITS,
the exec twin measures the tracking + FX + fee divergence.

### B.2 — Execute path (coded, SUSPENDED)

- Add `--venue us|ucits` to `execute-multistrat` (`cli.py`, default **`us`** — today's armed US-ETF
  IBKR-paper track is untouched). `--venue ucits` applies the B.1 map to `FUND_INSTRUMENTS` before building
  orders and records venue **`ibkr-ucits`**.
- An **unmapped sleeve → the execute path refuses** (never routes an unmapped order).
- No new sizing code: the `IbkrBroker` whole-share path already exists (`supports_fractional = False`,
  int-rounded `MarketOrder`). UCITS prices ($10–100/share) make whole-share rounding ~1–2%, acceptable for
  risk-parity at $35k.
- A **suspended** `fxhnt-ucits-rebalancer` CronJob on a **monthly** schedule (per-order-min friction control
  at $35k). The modeled `multistrat_nav` stays daily; only execution cadence is monthly.
- Records an `ibkr-ucits` exec twin (via the existing `multistrat_exec` twin path + `_safe_book_venue`
  badge) reconciling executed-UCITS-vs-modeled-US.

**Cutover (later, operational):** arm `fxhnt-ucits-rebalancer` + `suspend: true` the US-ETF
`fxhnt-ibkr-rebalancer`, once a Euronext-enabled account exists. The modeled series never moves.

## Data Flow

```
combined_book_forward_job (nightly)
  → vrp_nav: append XSP OPRA bars to xsp_option_bars PIT → recompute VrpStrategy (real-mark defined-risk
      XSP put-spread ladder) → run_track(repo, "vrp", VrpStrategy) → forward_summary/nav + persist_backtest_ref
  → multistrat_nav: UNCHANGED (models US underlyings)
  → cockpit_forward: evaluate the reconciliation gates (forward vs real ref), surface in the cockpit
Nightly record_allocation (when vrp promotes to deploy):
  → compute_allocation includes vrp; K = min(k_vol, k_cvar) → CVaR floor de-levers the fat-left tail
Execution (both SUSPENDED):
  → execute-vrp (IBKR XSP mleg) → vrp_exec twin
  → execute-multistrat --venue ucits (IBKR UCITS whole-share) → ibkr-ucits exec twin
```

## Error Handling

- **VRP data:** missing/short OPRA history or a degenerate IV inversion (no arb-free solution, `τ≤0`,
  crossed/zero mark) → skip that contract; if the ladder can't be marked, book that day flat (track warms,
  never crashes). OPRA `get_cost` breach → refuse (backfill Job runs chunked under a raised cap).
- **VRP gate:** no `backtest_summary` ref → the reconciliation gate silently degrades to a bare timer (known
  failure mode) — `persist_backtest_ref=True` in `vrp_nav` prevents it; a test asserts the ref row is
  written.
- **VRP recompute stability:** frozen PIT bars → the record doesn't drift on any OPRA revision (the
  multistrat lesson, applied to options).
- **UCITS:** an unmapped sleeve → `execute-multistrat --venue ucits` refuses (never routes an unmapped
  order); `--venue ucits` on a non-Euronext account → IBKR rejects → recorded as a gap row (suspended
  anyway).

## Testing

**VRP unit (`tests/unit/test_vrp_book.py`):**
- BS/IV helpers: put price vs a known reference; `d1/d2`; IV-inversion round-trip (price → IV → price within
  tol); put-call-parity forward on a synthetic ATM pair.
- 20Δ strike selection: on a synthetic chain, the chosen short strike has `|Δ| ≈ 0.20`.
- `VrpStrategy.advance`: on a synthetic OPRA path, a plausible defined-risk series (bounded per-spread loss =
  width; positive credit-decay on calm days; capped loss on a crash day); `advance(None,{})` returns the full
  inception series; recompute is deterministic (same frozen input → same series).
- Fat-left-tail property: the produced series has `cvar_daily/std > 1.755` → feeding it to
  `compute_allocation` yields `K_cvar < K_vol` (connects to the merged CVaR engine).

**VRP integration:**
- `databento.py` options path: `fetch_option_chain` / `fetch_option_bars` parse OPRA `definition` +
  `ohlcv-1d` into the expected shapes (against a recorded fixture, not a live pull); cost-guard refuses over
  cap.
- `xsp_option_bars` repo: `upsert_bars` / `bars_for` / `chain_asof` round-trip.
- `vrp_nav` asset recomputes + writes `forward_summary` + a real `backtest_summary` (ref) row;
  `definitions.py` loads with `vrp_nav` in the job + `cockpit_forward` deps; `migrate-forward-anchors` seeds
  the `vrp` anchor.

**UCITS unit (`tests/unit/test_ucits_routing.py`):**
- Map application: `--venue ucits` rewrites `FUND_INSTRUMENTS` → UCITS symbols; `--venue us` (default) is
  unchanged.
- Unmapped-sleeve refusal.
- Whole-share rounding on UCITS prices.

**Local cockpit verify (Playwright):** `/strategy/vrp` renders NAV + gate (building N/21) + the real
backtest ref; multistrat detail shows the `ibkr-ucits` badge path via `_safe_book_venue`. Read the numbers —
never claim "works" off synthetic events or a prod deploy.

## File Touchpoints

| File | Change |
|---|---|
| `src/fxhnt/adapters/data/databento.py` | **options path** — `AssetClass.OPTION` branch, `fetch_option_chain`, `fetch_option_bars` (OPRA.PILLAR, definition + ohlcv-1d) |
| `src/fxhnt/adapters/persistence/` (+ migration) | **new** `xsp_option_bars` PIT table + repo (`upsert_bars`/`bars_for`/`chain_asof`) |
| `src/fxhnt/application/vrp_book.py` | **new** — BS/IV helpers + put-call-parity forward + `VrpStrategy` (real-mark defined-risk XSP put-spread ladder) |
| `src/fxhnt/adapters/orchestration/assets.py` | **new** `vrp_nav` asset (mirror `multistrat_nav`) + `cockpit_forward` dep |
| `src/fxhnt/adapters/orchestration/definitions.py` | add `vrp_nav` to the job + `Definitions.assets` |
| `src/fxhnt/registry.py` | `"vrp"` entry (venue `ibkr-xsp`, recompute, reconciliation gate, `promote_on_pass` False) |
| `src/fxhnt/adapters/orchestration/migration_builders.py` | `builders["vrp"]` anchor seeding |
| `src/fxhnt/adapters/broker/ibkr.py` | **options `mleg`** combo path (suspended exec) |
| `src/fxhnt/cli.py` | `execute-vrp` command; `--venue us\|ucits` on `execute-multistrat` |
| `src/fxhnt/config.py` | `FXHNT_UCITS_MAP` US→UCITS instrument map settings |
| `infra/k8s/jobs/fxhnt-vrp-rebalancer.yaml` | **new** suspended weekly CronJob + NetworkPolicy (OPRA/IBKR egress) |
| `infra/k8s/jobs/fxhnt-ucits-rebalancer.yaml` | **new** suspended monthly CronJob |
| tests | `test_vrp_book.py`, options-adapter + `xsp_option_bars` + `vrp_nav` integration, `test_ucits_routing.py`, Playwright cockpit |

## Deploy Notes

- **Schema change:** the `xsp_option_bars` PIT table (a migration). The one-time OPRA backfill (2013→) runs
  as a separate Job under a raised cost cap, chunked by month — not on cockpit startup (no heavy DDL/backfill
  in app startup per `feedback_no_heavy_ddl_in_app_startup`).
- Next nightly runs `vrp_nav` (append + recompute + ref); the gate shows building N/21.
- `promote_on_pass` is **off** — `vrp` will NOT auto-fund even on gate PASS; promotion to deploy (and thus
  CVaR-sized capital) is a deliberate human step after watching the forward reconcile. Real money errs
  false-WAIT.
- Both exec CronJobs ship `suspend: true`. Arming = later operational step once an IBKR account with options
  / Euronext permission exists.
