docs(fund): tax/fund structure brief + EUR fund-ledger design spec

Research brief (6-agent workflow): NL tax/fund/accounting landscape for
going live. Resolved structure given operator's existing tech-BV + holding
+ DGA-salary: arbeid taxed in the BV, dividend (cleanly taxed) to private,
passive fund capital in Box 3 -> removes the Box-1 reclassification risk.

EUR-functional fund-ledger design spec: append-only double-entry ledger in
the existing Postgres warehouse, IBKR Flex ingestion, FIFO, fund-only scope.
No conversion pipeline (EUR end-to-end). NOT tax advice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:00:13 +00:00
parent 8df02c2198
commit fb14ddc75d
3 changed files with 803 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
# fxhnt — Fund / Tax / Accounting Research Brief (2026-07-20)
**> This is researched context, NOT legal, tax, or financial advice.** The Dutch tax rules that govern this the most (Box 3 wealth taxation and the FGR fund regime) are in **active reform** as of mid-2026: Box 3's actual-return system is legislatively unsettled (Eerste Kamer voting paused) and the FGR definition was rewritten Jan 2025 with further consultation open. Every rate, threshold, and classification below must be confirmed with a licensed Dutch belastingadviseur / fiscaal jurist against the finalized Belastingdienst publications for your actual filing year before you rely on it. The single highest-stakes item — whether your systematic/algorithmic trading is Box 3 or Box 1 — is genuinely unsettled case law and needs a written advisor opinion (and possibly a vooroverleg with the Belastingdienst) *before* you scale real capital.
**> STATE NOTE (2026-07-20): the fund is STILL PAPER — no live book yet** (IBKR DU9600528 is a paper account; Bybit exec never armed). This is go-live planning from euro zero, and the Bybit ~1 Jul 2026 EEA change (§5.15) is **not** a live-position emergency — just a reason to open the right Bybit-EU (EUR-not-USDT) account before funding crypto.
**> RESOLVED STRUCTURE (2026-07-20) — supersedes §1's "start personal, then decide" and §3/§4's BV-from-zero modelling.** The operator surfaced that they **already have an IT/tech-BV + holding + a DGA-salary**. That collapses the whole Box-1-vs-Box-3 gamble into a clean, already-in-place structure:
> **Tech-BV = ALL the arbeid** (build/run the system, buy/develop technology) — already taxed there (Vpb 19% + the existing DGA loon, so *no extra €58k gebruikelijk-loon is needed*). → BV pays **dividend** (Vpb 19% + Box 2 24.5%, cleanly taxed) to private. → that al-belaste private money is invested in the **fund as passive capital → Box 3** (~6% forfait × 36% ≈ 2.2%/yr of assets, regardless of real return).
This **removes the Box-1 reclassification risk entirely** — the only real threat — because the danger was ever *arbeid + winst in one hand* (what sank GHARL:2024:4613). Here the labour is taxed in the BV and the private capital does nothing but *renderen*: exactly the "normaal vermogensbeheer" the daytrader cases won on. **No new entity, no fund wrapper, no AIFMD (own money). Dividend is an option the operator HAS, not a forced cost.** Two design guards remain (not risks): keep the fund genuinely passive → price any tech-BV→fund service **at arm's length**; and time the dividend for the Box 2 bracket. So §1's staged "personal → BV → fund" path and §3/§4's break-even math are **background theory** for this operator — the resolved route above is what applies. The advisor conversation shifts from *"which box?"* to *"how do I price the tech-BV→fund service so the fund stays demonstrably passive?"*
**> ACCOUNTING NOTE: EUR is the functional currency, no conversion pipeline.** IBKR is EUR-native and Bybit-EU is EUR too → there is no USD leg. §2's FX-revaluation subsystem (realized/unrealized-FX split, ECB-rate join, USDT proxy) is **downgraded from core to dormant insurance**: keep a `currency` + `fx_rate_to_eur` column per posting (default EUR/1.0) so a stray non-EUR line is representable, but build no conversion pipeline. Pure-EUR book = no FX P&L = cleaner books and cleaner tax.
---
# Decision brief: bookkeeping, profit extraction, and structure for an NL-resident agentic quant fund
## 1. Bottom line up front
**Start personal (Box 3), keep your own capital, and build the accounting system now — but get a written advisor opinion on the Box 1 reclassification risk before you go live with real money.** At ~$35k pilot scale, a BV's fixed costs (incorporation, annual accounts, Vpb filing, and especially the ~€58k gebruikelijk-loon obligation that an actively-run trading BV likely cannot escape) are disproportionate, and every pooled-fund wrapper (FGR/VBI/FBI/RAIF/QIAIF) is either closed off post-2025 reform or needs tens of millions in AUM to be economical. The one serious caveat: your setup — a continuously-running, code-driven, systematic strategy — sits squarely on the fact pattern (ECLI:NL:GHARL:2024:4613, the self-built crypto bot with a stable 7580% win rate) that Dutch courts pushed into **Box 1** (progressive rates to ~49.5% on actual profit) rather than Box 3, so "Box 3 is cheaper" is only true if Box 3 actually holds. **Move to a dedicated (ring-fenced) BV when either (a) your advisor judges Box 1 reclassification risk high enough that corporate-rate certainty is worth the overhead, or (b) profits are large and consistently retained/reinvested so Vpb-only compounding (19%) beats paying Box 3 tax on capital every year.** Move to a fund structure (de-minimis AIFM + FGR) only when external capital is a concrete, near-term plan — not before.
## 2. The accounting system (the part you can build)
**Recommendation: build the tax ledger inside your existing Postgres/Timescale warehouse, not in a plaintext tool (beancount/hledger).** You already run Postgres + Dagster with per-strategy tagging; a second, disconnected file-based ledger just creates a reconciliation surface between the warehouse and the books. beancount's one real advantage — a native FIFO/AVERAGE cost-basis engine — is a bounded amount of SQL to replicate, and neither tool has a Bybit importer (you build that either way). Keep beancount only as a *possible export target* if your accountant wants to inspect plain text.
### Schema (append-only double-entry)
- **`journal_entries`** (header): `entry_id, entry_date, description, source_venue, strategy_tag, source_ref` — immutable, corrections via reversing entries only (never UPDATE/DELETE).
- **`journal_postings`** (lines): `entry_id, account_id, side ('debit'|'credit'), amount, currency, fx_rate_to_eur, eur_amount` — with a DB-level CHECK/trigger enforcing that each entry's postings sum to zero **in EUR-converted terms**.
- **`accounts`**: per-venue sub-ledgers that each balance standalone — `IBKR-EUR-cash`, `IBKR-securities`, `Bybit-USD-cash`, `Bybit-crypto`, `Bybit-perp-margin` — plus **two distinct FX accounts per foreign balance**: `unrealized-FX-PnL` (hit at each period-end retranslation) and `realized-FX-PnL` (hit only on actual conversion/withdrawal). This split is what lets you resolve the accounting-vs-tax realization question later without re-deriving history.
- **`cost_basis_lots`** (per venue/asset): matched lots so realized gains come from actual lot-matching (FIFO — the NL de-facto default — or whatever your advisor confirms, applied *consistently* across both venues), not cash-flow deltas.
- **`fx_rates`**: daily ECB reference rates, keyed by value_date.
- **`account_balances`**: a materialized view for fast reads.
The **strategy_tag** on every posting makes P&L-by-strategy a `GROUP BY`, reusing your existing Dagster attribution — a filter, not a rebuild.
### Ingestion pipeline (Dagster assets, mirroring your nightly ETL pattern)
1. **IBKR Flex Web Service API** (not PDF scraping): a per-query token + query ID, 2-step SendRequest→GetStatement poll, ~1 req/sec limit, 23 pulls/day is safe. Pull trades, cash transactions, dividends, corporate actions, positions, NAV. Archive the annual Activity Statement as an audit doc even though you compute P&L independently. Note: as a W-8BEN non-US person you get a **1042-S**, never a Consolidated 1099, and there is no IBKR "NL tax statement."
2. **Bybit** transaction-history / Tax-API REST endpoints for fills, **funding fees**, and deposits/withdrawals. **Explicitly verify the Unified Trading Account API surfaces perp funding and mark-to-market events** — they are easy to miss and material to P&L.
3. Land raw payloads in an append-only `raw` schema (mirrors your DB-anchor-SSOT pattern), then a transform layer normalizes both venues into the canonical `journal_postings`.
### FX conversion & reconciliation
- **Standard rate: ECB daily reference rate** (`eurofxref-daily.xml`, ~16:00 CET; `eurofxref-hist.xml` for one-time backfill), applied at each transaction's value_date (IAS 21 transaction-date convention).
- **The ECB publishes no crypto crosses.** Two-step for Bybit: (1) treat USDT as a USD-proxy ~1:1 **and document that assumption explicitly** (or use Bybit's own USD-index for USDT legs), then (2) USD→EUR at the ECB rate for that value_date.
- **Box 3 peildatum (1 January)** crypto valuation: use the **exchange's own displayed value at 00:00 on 1 Jan** (crypto is venue-priced), and snapshot it via API.
- **Reconciliation target: DAC8/CARF.** From FY2026, in-scope CASPs report your crypto data straight to the Belastingdienst (first reporting Jan 2027). Your books must reconcile to what the venue reports. Given Bybit's current global entity is non-EU pre-migration, **assume no third-party feed and keep fully independent records** — this raises, not lowers, your self-reporting burden. (See §5 on the Bybit EU migration deadline.)
## 3. Tax-efficient profit extraction (ranked)
| Rank | Route | Box / rate implication | When it wins |
|---|---|---|---|
| **1** | **Stay personal, Box 3** | 36% on a *deemed* return (~6% "overige bezittingen" forfait for 2026 → effective ~2.2% of asset value/yr); tegenbewijsregeling lets you report actual return if lower | Pilot / own-capital, **if Box 3 classification holds** and actual returns exceed the forfait. Dramatically cheaper than any actual-profit regime in a strong year. |
| **2** | **BV, retain earnings** | Vpb only: 19% ≤€200k, 25.8% above — no Box 2 layer until you distribute | Once profits are large and **reinvested/compounded** inside the BV; deferral of the Box 2 layer is the compounding advantage. Also removes the Box 1/3 question entirely. |
| **3** | **BV, distribute as dividend** | Vpb (19/25.8%) **then** Box 2 (24.5% ≤~€68.8k / 31% above); combined effective ~3844% on distributed profit | Only when you actually need the cash personally *and* Box 1 reclassification would otherwise have applied (~49.5%) — you pay for certainty. |
| **4** | **DGA salary from a BV** | gebruikelijk loon ~€58k (2026), taxed as Box 1 wage income, progressive to ~49.5% | Not a "choice" — it's a *mandatory* cost of the BV route (an active trading BV is unlikely to qualify for the ~€5k limited-activity carve-out, and from 2026 needs an advisor-cosigned salary justification). Factor it into the BV break-even, don't treat it as extraction. |
**Key numbers to hold in mind:** Box 3 taxes *deemed* return on *capital*; the BV route taxes *actual* profit. So Box 3 is cheaper the more your real return exceeds ~6%, and the BV is cheaper mainly when Box 1 (~49.5% on actual) is the realistic alternative or when earnings are retained. Under the Box 3 **tegenbewijsregeling**, "actual return" includes **unrealized** mark-to-market gains and does **not** allow deduction of trading fees/funding (only actual Box-3 debt interest) — this is *not* how a trading P&L system naturally computes returns, so your ledger needs a parallel tax-basis calculation.
## 4. Fund-structure decision map
```
STAGE 0 — Personal, Box 3 own capital only, any size
trigger to advance ↓ external capital contemplated, OR Box 1 risk judged high, OR large retained profits
STAGE 1 — Dedicated BV (ring-fenced) own capital; new sister/subsidiary BV, NOT commingled with bizworx
trigger to advance ↓ first external euro is concrete & near-term
STAGE 2 — BV(manager) + FGR + AIFMD "light" registration
trigger to advance ↓ AUM approaches ~€2030m (dedicated-fund fixed costs become proportionate)
STAGE 3 — Licensed fund / RAIF (LU) or QIAIF (IE) institutional external capital, tens of €m+
```
**Regulatory gates:**
- **"Own money only" is a genuine AIFMD safe harbor** (Art. 4(1)(a); Recital 7 family-office carve-out). No license, no registration while you trade solely your own capital — *this covers a solo BV too*. Also outside MiFID II via the Art. 2(1)(d) own-account exemption (subject to a technical check that your order/message rate doesn't hit the "high-frequency algorithmic trading" threshold), and outside MiCA CASP authorization for own-account crypto.
- **The moment you accept one external euro**, AIF/AIFMD scope begins. Below **€100m AUM** (leveraged / redemption within 5yr) or **€500m** (unleveraged closed-end), you need only **AFM registration** (the "light"/de-minimis regime), *not* a full AIFM license — but that registration must be done **before** the first external euro, plus Wwft (AML) and Sanctiewet compliance and AFM/DNB reporting. Marketing to retail adds top-up rules (Wft 4:37p) and a <150-investor / €100k-minimum-ticket cap.
- **Ring-fence, don't commingle.** If/when you do incorporate, put trading in a **new dedicated BV**, not inside bizworx.nl — standard NL practice isolates a volatile, potentially loss-making book from an operating business's balance sheet, credit, and any regulatory status.
- **FGR is in flux and not fit-for-purpose at pilot scale.** The Jan-2025 reform abolished the open/closed test; a new fund must qualify directly under Wft-anchored rules and a further consultation (opened Dec 2025) is open. VBI is effectively closed to solo operators post-reform; FBI structurally disfavors active high-turnover trading; RAIF/QIAIF both *require* an external authorized AIFM and are uneconomical below ~€2030m. So Stage 2 is a *later* decision, timed to land after the FGR rules settle.
## 5. What to take to a Dutch tax advisor / notaris
**Classification (highest stakes):**
1. Given a fully agentic, continuously-running systematic strategy across IBKR UCITS ETFs + Bybit perps — is this **Box 3 or Box 1** (resultaat uit overige werkzaamheden / winst uit onderneming)? Reconcile the taxpayer-favorable *Gelderland/Noord-Holland* daytrading line against the *GHARL:2024:4613* bot line that landed in Box 1. **Is a vooroverleg / advance ruling advisable before scaling capital**, given the retroactive-assessment downside if reclassified after the fact?
**Entity & existing structure:**
2. Is **bizworx.nl a BV or an eenmanszaak?** This determines whether housing trading is a fresh incorporation or a restructuring (eenmanszaak→BV has geruisloze inbreng considerations; a fiscale eenheid question if a second BV is created).
3. If moving personally-held IBKR/Bybit positions into a BV — does that trigger a **deemed-disposal (afrekening)** for Box 3, and is any rollover/deferral available for a trading portfolio?
4. **gebruikelijk loon**: does running an autonomous/agentic system (software does most of the "work") qualify for the ~€5k limited-activity carve-out, or does the algorithm-design labor count against it?
**Accounting method & FX:**
5. **Cost-basis method** — FIFO or weighted-average, and must it be identical across IBKR ETF lots and Bybit crypto lots (goedkoopmansgebruik consistency)?
6. **Unrealized FX/crypto revaluation** — book to P&L each period (RJ122/IAS21) for internal reporting while the tax return recognizes only realized (goedkoopmansgebruik; the NLFiscaal unrealized-FX-loss-denial precedent)? Do I need two parallel FX-recognition tracks (book vs tax)?
7. **Functional-currency election** (EUR vs USD) — worth the 10-year lock-in, or is "EUR presentation + translate every Bybit leg at transaction-date ECB rate" sufficient without electing?
8. **USDT** — treated as USD-equivalent, or its own crypto-asset (own EUR cross) for both ongoing bookkeeping and the Box 3 peildatum?
**Instrument-specific (a genuine gap in the research):**
9. How are **perpetual-futures funding payments** characterized for Box 3 werkelijk-rendement — as income each period, or folded into position P&L? No authoritative Belastingdienst guidance was found.
10. Under werkelijk rendement, are Bybit **trading fees / funding paid / margin costs non-deductible** (as the tegenbewijsregeling mechanics suggest, unlike normal trading P&L)?
**Reform & reporting:**
11. Confirm the **finalized 2026 figures** at filing time (heffingsvrij vermogen — sources conflicted between ~€51,396 and ~€59,357 — forfait %, Box 2 thresholds).
12. Does the fund trigger a formal **boekhoudplicht** (BW 2:10 / 3:15i — 7-year retention, audit-trail format), and does the Postgres pipeline satisfy it?
13. **BTW/VAT** — confirm own-account securities/crypto trading is exempt, and re-check if a management fee is later introduced.
**Regulatory (Wft lawyer, not just belastingadviseur):**
14. Does the IBKR order/message rate meet MiFID II's **HFT** technical threshold (removes the own-account exemption)? Needs a real order-flow review.
15. **Bybit EU migration — operationally urgent:** the global platform closes EEA/NL access ~1 July 2026; you migrate to the Austria-MiCA "Bybit EU" entity (fresh KYC, Travel Rule proof-of-wallet for transfers >€1,000). **Bybit EU reportedly cannot offer USDT** (Tether has no MiCA EMT authorization), so USDT balances must convert to EUR or a MiCA stablecoin — **is that forced conversion a taxable disposal, and under which box?** Verify directly on Bybit's compliance pages before the deadline.
## 6. Immediate next build step
**Build the append-only double-entry ledger core + the IBKR Flex ingestion asset first — one Dagster asset landing IBKR Flex trades/cash/NAV into a `raw` schema, normalizing into `journal_entries`/`journal_postings` with the balanced-entry DB trigger, and joining an ECB `fx_rates` table by value_date to produce EUR-normalized postings.**
Why this one component first, over everything else:
- IBKR is your EUR-native, better-documented venue with a stable Flex Web Service API and an existing importer pattern to model on — lowest integration risk, so you validate the schema and the balanced-entry invariant against real data before adding Bybit's messier surface (funding fees, USDT proxy, migration churn).
- It exercises **every** load-bearing design decision at once: append-only immutability, the EUR-sum-to-zero constraint, the strategy_tag dimension, and the ECB-rate join — the FX-revaluation split and cost-basis lot tables extend cleanly on top once this spine is proven.
- It's a natural Dagster asset alongside your existing nightly ETL, reusing your monitoring/DB-anchor-SSOT patterns — no new infrastructure, and it turns tax season into a query rather than an annual export scramble regardless of which box you end up in.
Defer the Bybit importer, the cost-basis lot engine, and any beancount export until this spine is landing correct, reconciled EUR postings from IBKR.

View File

@@ -0,0 +1,599 @@
# Positioning Per-Coin Gross Cap 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:** Add an ex-ante per-coin gross-weight cap (0.07) to the `positioning` sleeve so no single coin dominates the book, then re-inception + backfill the forward anchor so the live gate keeps running against the capped construction.
**Architecture:** The cap is applied to the FORMING weights in `positioning_weights` (before returns), so it is strictly causal (no lookahead). It threads through the existing seams (`_book_breakdown``positioning_returns_from_store``_positioning_series``sleeve_returns_from_store`) as an optional `coin_gross_cap` param that defaults to `None` (OFF → byte-identical to today). It is switched ON at 0.07 only in `bybit_book_persist.py`. The registry `positioning` + `bybit_4edge` definition versions bump to re-inception; `inception_t0` pins the existing OOS start (2026-07-06) so the forward track backfills the window with the capped construction instead of resetting the gate to 0.
**Tech Stack:** Python 3.12, numpy-free pure math, pytest (`-n auto`), TimescaleFeatureStore (Postgres, table `bybit_features`).
## Global Constraints
- The cap is applied to WEIGHTS (in `positioning_weights`), NOT to returns/contributions — it must be ex-ante/causal (the cap decision cannot see the coin's realized return).
- `coin_gross_cap` default is `None` (OFF) at EVERY seam. With it OFF, every existing test and the whole backtest must be byte-identical to today. The OFF path must not add per-term division drift.
- After clipping `|w| ≤ coin_gross_cap`, RENORMALIZE the day's weights to unit gross (`Σ|w| = 1`), matching `positioning_weights`' existing unit-gross contract.
- The validated cap value is **0.07** (full-history sweep 1295d: Sharpe 2.60→2.69, maxDD 32.3%→29.9%, per-year Sharpe non-decreasing every year). Do NOT use 0.04 (falsified: Sharpe drops to 2.43).
- Re-inception uses the EXISTING pattern: bump `version` in `definition.params.version`, and set `inception_t0: {"date": "2026-07-06", "version": <new>}` (outside `params`, NOT hashed) so the anchor backdates and the forward window is recomputed, not reset. See `forward_definition.inception_override`.
- `positioning` is a CONSTITUENT of `bybit_4edge`; both registry entries' definition versions must bump together (the book's sleeve construction changed).
- Never touch real money / execution. This is a construction change to the modeled+forward track only.
- Run tests with `uv run --extra dev python -m pytest ...`. The positioning eval integration tests need no extra services (they use a fake store).
---
### Task 1: Add the ex-ante `coin_gross_cap` to `positioning_weights`
**Files:**
- Modify: `src/fxhnt/application/bybit_positioning_eval.py:78-104` (the `positioning_weights` function)
- Test: `tests/integration/test_bybit_positioning_eval.py`
**Interfaces:**
- Consumes: nothing new.
- Produces: `positioning_weights(ratio_by_day, *, direction="contrarian", coin_gross_cap: float | None = None) -> dict[int, dict[str, float]]`. When `coin_gross_cap` is a float, each day's weights are clipped to `|w| ≤ coin_gross_cap` then renormalized to unit gross. When `None`, output is byte-identical to today.
- [ ] **Step 1: Write the failing test**
Add to `tests/integration/test_bybit_positioning_eval.py`:
```python
def test_coin_gross_cap_clips_and_renormalizes() -> None:
# One coin dominates (retail extreme) -> uncapped weight far exceeds 0.07; cap must clip + renormalize.
ratio = {0: {"DOMUSDT": 0.95, "AUSDT": 0.50, "BUSDT": 0.50, "CUSDT": 0.05}}
uncapped = positioning_weights(ratio)[0]
assert max(abs(w) for w in uncapped.values()) > 0.07 # DOM dominates uncapped
capped = positioning_weights(ratio, coin_gross_cap=0.07)[0]
assert max(abs(w) for w in capped.values()) <= 0.07 + 1e-9 # no coin exceeds the cap
assert abs(sum(abs(w) for w in capped.values()) - 1.0) < 1e-9 # still unit gross
# sign structure preserved: DOM still short (over-long crowd), C still long (over-short)
assert capped["DOMUSDT"] < 0.0 and capped["CUSDT"] > 0.0
def test_coin_gross_cap_none_is_byte_identical() -> None:
ratio = {0: {"AAAUSDT": 0.9, "BBBUSDT": 0.5, "CCCUSDT": 0.1}}
assert positioning_weights(ratio, coin_gross_cap=None) == positioning_weights(ratio)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py::test_coin_gross_cap_clips_and_renormalizes -v`
Expected: FAIL with `TypeError: positioning_weights() got an unexpected keyword argument 'coin_gross_cap'`
- [ ] **Step 3: Implement the cap in `positioning_weights`**
Replace the body of `positioning_weights` (lines 91-104) so the signature gains `coin_gross_cap` and, after the existing unit-gross normalization, applies the clip+renormalize when the cap is set:
```python
def positioning_weights(ratio_by_day: dict[int, dict[str, float]], *,
direction: str = "contrarian",
coin_gross_cap: float | None = None) -> dict[int, dict[str, float]]:
"""`{day: {coin: w}}` market-neutral cross-sectional weights from the long/short positioning signal.
`direction="contrarian"` (DEFAULT) SHORTs the high-buyRatio coins (retail over-long → fade) and LONGs the
low-buyRatio coins (retail over-short). `direction="momentum"` is the EXACT SIGN-FLIP: LONG the over-long
/ SHORT the over-short (informed-flow continuation). Construction per day:
1. DEMEAN the per-day buyRatio across coins (so the book is dollar/market-neutral, Σw ≈ 0);
2. apply the DIRECTION sign — contrarian = demeaned (short the rich/over-long), momentum = +demeaned;
3. normalise to UNIT GROSS (Σ|w| = 1);
4. (OPTIONAL) if `coin_gross_cap` is set, CLIP each |w| to the cap and RENORMALISE to unit gross —
an EX-ANTE per-coin concentration limit (applied to the FORMING weight, so it is strictly causal:
it cannot see the coin's realized return). Validated at 0.07 (full-history sweep: Sharpe 2.60→2.69,
maxDD 32.3%29.9%, per-year non-decreasing) — caps the LABUSDT-class implosions (a 10.3% long leg
that did 59%/79.5% on 2026-07-06/07) without over-throttling the edge (0.04 falsified: Sharpe→2.43).
Because the gross is identical for both directions, `momentum` weights are the exact per-coin negation of
`contrarian`. A day with <2 coins, or zero gross after demeaning, yields no weights (skipped). Pure."""
if direction not in ("contrarian", "momentum"):
raise ValueError(f"direction must be 'contrarian' or 'momentum', got {direction!r}")
sign = -1.0 if direction == "contrarian" else 1.0
out: dict[int, dict[str, float]] = {}
for day, row in ratio_by_day.items():
if len(row) < 2:
continue
mean = st.mean(row.values())
raw = {c: sign * (v - mean) for c, v in row.items()}
gross = sum(abs(w) for w in raw.values())
if gross <= 0.0:
continue
w = {c: x / gross for c, x in raw.items()}
if coin_gross_cap is not None:
w = {c: max(-coin_gross_cap, min(coin_gross_cap, x)) for c, x in w.items()}
capped_gross = sum(abs(x) for x in w.values())
if capped_gross <= 0.0:
continue
w = {c: x / capped_gross for c, x in w.items()}
out[int(day)] = w
return out
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py -v`
Expected: PASS (the two new tests + all existing positioning_weights tests still green)
- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/bybit_positioning_eval.py tests/integration/test_bybit_positioning_eval.py
git commit -m "feat(positioning): ex-ante per-coin gross cap in positioning_weights (default off)"
```
---
### Task 2: Thread `coin_gross_cap` through `_book_breakdown` and `positioning_returns_from_store`
**Files:**
- Modify: `src/fxhnt/application/bybit_positioning_eval.py:140-166` (`_book_breakdown` signature + the `positioning_weights` call at line 177)
- Modify: `src/fxhnt/application/bybit_positioning_eval.py:287-303` (`positioning_returns_from_store` signature + its `_book_breakdown` call)
- Test: `tests/integration/test_bybit_positioning_eval.py`
**Interfaces:**
- Consumes: `positioning_weights(..., coin_gross_cap=...)` from Task 1.
- Produces:
- `_book_breakdown(store, *, universe, cost_bps, direction, capacity_capital=None, participation=0.10, turnover_window=30, tail_cap_k=None, book_vol_window=60, coin_gross_cap=None) -> dict`
- `positioning_returns_from_store(store, *, universe=None, cost_bps=5.5, direction="contrarian", capacity_capital=None, tail_cap_k=None, coin_gross_cap=None) -> dict[int, float]`
- [ ] **Step 1: Write the failing test**
Add to `tests/integration/test_bybit_positioning_eval.py` (reuse the module's existing fake store fixture — grep the file for the store used by `test_capacity_haircut_caps_gain_only_at_scale`, e.g. a `_FakeStore`/`_ReadOnlyStore` builder, and construct one where a single coin has an extreme long_ratio + a large adverse next-day return):
```python
def test_coin_gross_cap_reduces_single_coin_loss_in_book(positioning_store_with_shock):
# positioning_store_with_shock: one coin (retail over-short) craters next day; uncapped it dominates the
# book loss, capped its weight (and thus its loss contribution) is bounded.
store = positioning_store_with_shock
uncapped = positioning_returns_from_store(store, cost_bps=0.0)
capped = positioning_returns_from_store(store, cost_bps=0.0, coin_gross_cap=0.07)
shock_day = min(uncapped, key=lambda d: uncapped[d]) # the worst day uncapped
assert capped[shock_day] > uncapped[shock_day] # the cap made the worst day less bad
```
If the module has no reusable multi-coin shock fixture, build the store inline in the test from a `long_ratio` panel (one coin at ratio 0.95, three near 0.5) + a `close` panel where the over-short coin drops ~50% the next day, mirroring the `test_capacity_haircut_*` store construction in the same file.
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py::test_coin_gross_cap_reduces_single_coin_loss_in_book -v`
Expected: FAIL with `TypeError: positioning_returns_from_store() got an unexpected keyword argument 'coin_gross_cap'`
- [ ] **Step 3: Thread the param through both functions**
In `_book_breakdown`, add `coin_gross_cap: float | None = None` to the signature (after `book_vol_window: int = 60`), and change the weight-forming call (currently line 177):
```python
weights_by_day = positioning_weights(_ratio_by_day(long_ratio), direction=direction,
coin_gross_cap=coin_gross_cap)
```
In `positioning_returns_from_store`, add `coin_gross_cap: float | None = None` to the signature (after `tail_cap_k`) and pass it through:
```python
return _book_breakdown(store, universe=universe, cost_bps=cost_bps, direction=direction,
capacity_capital=capacity_capital, tail_cap_k=tail_cap_k,
coin_gross_cap=coin_gross_cap)["net"]
```
Update the `positioning_returns_from_store` docstring's param note to add: `coin_gross_cap` (None default) applies the ex-ante per-coin concentration cap (see `positioning_weights`).
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/bybit_positioning_eval.py tests/integration/test_bybit_positioning_eval.py
git commit -m "feat(positioning): thread coin_gross_cap through book_breakdown + returns_from_store"
```
---
### Task 3: Thread `coin_gross_cap` through the sleeve seam (`_positioning_series` + `sleeve_returns_from_store`)
**Files:**
- Modify: `src/fxhnt/application/bybit_book_eval.py:226-240` (`_positioning_series`)
- Modify: `src/fxhnt/application/bybit_book_eval.py:243-278` (`sleeve_returns_from_store` signature + the positioning branch)
- Test: `tests/integration/test_bybit_positioning_eval.py` (or `tests/integration/test_web_new_edges.py` if that is where sleeve_returns_from_store is exercised — grep first)
**Interfaces:**
- Consumes: `positioning_returns_from_store(..., coin_gross_cap=...)` from Task 2.
- Produces:
- `_positioning_series(store, *, universe, cost_bps, direction="contrarian", capacity_capital=None, tail_cap_k=None, coin_gross_cap=None) -> dict[int, float]`
- `sleeve_returns_from_store(store, edge, *, universe=None, cost_bps=0.0, unlock_events=None, stablecoin_spot_panel=None, capacity_capital=None, tail_cap_k=None, coin_gross_cap=None) -> dict[int, float]`. The `coin_gross_cap` is forwarded ONLY to the `positioning` branch (other edges ignore it).
- [ ] **Step 1: Write the failing test**
Add (same test file, reuse the store from Task 2's fixture/inline construction):
```python
def test_sleeve_returns_positioning_forwards_coin_gross_cap(positioning_store_with_shock):
from fxhnt.application.bybit_book_eval import sleeve_returns_from_store
store = positioning_store_with_shock
uncapped = sleeve_returns_from_store(store, "positioning", cost_bps=0.0)
capped = sleeve_returns_from_store(store, "positioning", cost_bps=0.0, coin_gross_cap=0.07)
shock_day = min(uncapped, key=lambda d: uncapped[d])
assert capped[shock_day] > uncapped[shock_day] # cap forwarded -> worst day improved
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py::test_sleeve_returns_positioning_forwards_coin_gross_cap -v`
Expected: FAIL with `TypeError: sleeve_returns_from_store() got an unexpected keyword argument 'coin_gross_cap'`
- [ ] **Step 3: Thread through both functions**
In `_positioning_series` (line 226), add `coin_gross_cap: float | None = None` to the signature (after `tail_cap_k`) and forward it:
```python
return positioning_returns_from_store(store, universe=universe, cost_bps=cost_bps, direction=direction,
capacity_capital=capacity_capital, tail_cap_k=tail_cap_k,
coin_gross_cap=coin_gross_cap)
```
In `sleeve_returns_from_store` (line 243), add `coin_gross_cap: float | None = None` to the signature (after `tail_cap_k`), and change ONLY the positioning branch (line 276) to forward it:
```python
if edge == "positioning":
# ... existing comment about raw store + universe unchanged ...
return _positioning_series(store, universe=universe, cost_bps=cost_bps,
capacity_capital=capacity_capital, tail_cap_k=tail_cap_k,
coin_gross_cap=coin_gross_cap)
```
Leave the tstrend / unlock / stablecoin / xsfunding branches unchanged (they never receive `coin_gross_cap`).
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/bybit_book_eval.py tests/integration/test_bybit_positioning_eval.py
git commit -m "feat(positioning): forward coin_gross_cap through sleeve_returns_from_store"
```
---
### Task 4: Turn the cap ON at 0.07 in the live precompute + add the constant
**Files:**
- Modify: `src/fxhnt/application/bybit_forward_track.py:47` (add `POSITIONING_COIN_GROSS_CAP` next to `POSITIONING_TAIL_CAP_K`)
- Modify: `src/fxhnt/application/bybit_book_persist.py:77-84` (pass the cap into `sleeve_returns_from_store`)
- Test: `tests/integration/test_bybit_paper_reconciliation.py` (or the persist test — grep for a test that calls `_persist_bybit_book` / `persist_bybit_sleeve_returns`; if none exists at unit level, assert the constant is wired via a focused test in `test_bybit_positioning_eval.py`)
**Interfaces:**
- Consumes: `sleeve_returns_from_store(..., coin_gross_cap=...)` from Task 3.
- Produces: `POSITIONING_COIN_GROSS_CAP = 0.07` in `bybit_forward_track.py`; the live `_persist_bybit_book` sleeve computation applies it to the positioning sleeve.
- [ ] **Step 1: Write the failing test**
Add to `tests/integration/test_bybit_positioning_eval.py`:
```python
def test_positioning_coin_gross_cap_constant_is_007() -> None:
from fxhnt.application.bybit_forward_track import POSITIONING_COIN_GROSS_CAP
assert POSITIONING_COIN_GROSS_CAP == 0.07
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py::test_positioning_coin_gross_cap_constant_is_007 -v`
Expected: FAIL with `ImportError: cannot import name 'POSITIONING_COIN_GROSS_CAP'`
- [ ] **Step 3: Add the constant + wire it into the precompute**
In `src/fxhnt/application/bybit_forward_track.py`, directly after line 47 (`POSITIONING_TAIL_CAP_K = 4.0`):
```python
# Ex-ante per-coin gross-weight cap for the positioning sleeve — no single coin's |weight| may exceed this
# fraction of the book's unit gross. Validated 2026-07-20 over the full 1295-day history (Sharpe 2.60→2.69,
# maxDD 32.3%→29.9%, per-year Sharpe non-decreasing every year); caps LABUSDT-class single-coin implosions
# (a 10.3% long leg that did 59%/79.5% on 2026-07-06/07) without over-throttling (0.04 falsified → 2.43).
POSITIONING_COIN_GROSS_CAP = 0.07
```
In `src/fxhnt/application/bybit_book_persist.py`, update the import (line 78) and the sleeve computation (lines 80-83):
```python
from fxhnt.application.bybit_forward_track import POSITIONING_COIN_GROSS_CAP, POSITIONING_TAIL_CAP_K
sleeve_rets = {
s: sleeve_returns_from_store(store, s, universe=universe, cost_bps=cost_bps,
unlock_events=unlock_events,
capacity_capital=settings.paper_capital,
tail_cap_k=POSITIONING_TAIL_CAP_K,
coin_gross_cap=POSITIONING_COIN_GROSS_CAP)
for s in _DEFAULT_BYBIT_SLEEVES}
```
(`coin_gross_cap` is ignored by every sleeve except positioning — Task 3 only forwards it in the positioning branch — so passing it for all sleeves in the dict comprehension is correct and harmless.)
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_positioning_eval.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/application/bybit_forward_track.py src/fxhnt/application/bybit_book_persist.py tests/integration/test_bybit_positioning_eval.py
git commit -m "feat(positioning): enable coin_gross_cap=0.07 in the live bybit book precompute"
```
---
### Task 5: Bump definition versions to re-inception positioning + bybit_4edge (backdated anchor, no gate reset)
**Files:**
- Modify: `src/fxhnt/registry.py` (the `positioning` entry ~line 249-259 and the `bybit_4edge` entry ~line 198-211; also `bybit_4edge_levered` ~line 229-241 which shares the sleeve construction)
- Test: `tests/` — grep for `test_forward_definition` / a registry-consistency test; add an assertion that the bumped versions + inception_t0 are consistent
**Interfaces:**
- Consumes: the capped construction from Task 4 (the version bump signals the construction changed).
- Produces: `positioning`, `bybit_4edge`, `bybit_4edge_levered` definitions at `version: 3` with `inception_t0: {"date": "2026-07-06", "version": 3}`.
- [ ] **Step 1: Write the failing test**
Add `tests/unit/test_positioning_cap_reinception.py`:
```python
from fxhnt.registry import STRATEGY_REGISTRY
def test_positioning_and_book_bumped_to_v3_backdated():
for sid in ("positioning", "bybit_4edge", "bybit_4edge_levered"):
d = STRATEGY_REGISTRY[sid]["definition"]
assert d["params"]["version"] == 3, f"{sid} version not bumped to 3"
ov = d.get("inception_t0")
assert ov and ov["version"] == 3, f"{sid} inception_t0 not re-pinned to v3"
# backdated to the existing OOS start, NOT reset to today -> the forward window is recomputed, not lost
assert ov["date"] == "2026-07-06"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/unit/test_positioning_cap_reinception.py -v`
Expected: FAIL (versions are currently 2)
- [ ] **Step 3: Bump the three registry entries**
In each of the `positioning`, `bybit_4edge`, and `bybit_4edge_levered` entries in `src/fxhnt/registry.py`, change `"version": 2` to `"version": 3` inside `definition.params`, and change the `inception_t0` to `{"date": "2026-07-06", "version": 3}`. Note: `positioning`'s existing `inception_t0` date is `2026-07-06`; `bybit_4edge` / `bybit_4edge_levered` currently use `2026-07-07` — for this backfill set ALL three to `2026-07-06` (the positioning shock's first day) so the whole book re-warms over the same window. Add a comment on each:
```python
"inception_t0": {"date": "2026-07-06", "version": 3}, # v3: positioning coin_gross_cap=0.07 (2026-07-20 backfill)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run --extra dev python -m pytest tests/unit/test_positioning_cap_reinception.py tests/ -k "forward_definition or registry" -v`
Expected: PASS (the new test + any existing registry/definition consistency tests)
- [ ] **Step 5: Commit**
```bash
git add src/fxhnt/registry.py tests/unit/test_positioning_cap_reinception.py
git commit -m "feat(positioning): re-inception positioning+bybit_4edge v3 (coin_gross_cap, backdated 2026-07-06)"
```
---
### Task 6: Full-suite verification + lint/type gate
**Files:** none (verification task)
**Interfaces:** consumes all prior tasks.
- [ ] **Step 1: Run the full positioning + book + forward suite**
Run: `uv run --extra dev python -m pytest tests/ -k "positioning or book or forward or reconcil or bybit" -q`
Expected: all pass (no regression; the OFF-path byte-identity tests from Tasks 1-3 guarantee unchanged behavior everywhere the cap is not enabled).
- [ ] **Step 2: Lint + type-check the changed files**
Run: `uv run --extra dev ruff check src/fxhnt/application/bybit_positioning_eval.py src/fxhnt/application/bybit_book_eval.py src/fxhnt/application/bybit_book_persist.py src/fxhnt/application/bybit_forward_track.py src/fxhnt/registry.py`
Then: `uv run --extra dev --extra orchestration mypy src/fxhnt/application/bybit_positioning_eval.py src/fxhnt/application/bybit_book_eval.py`
Expected: no NEW errors on changed lines (pre-existing whole-file findings that are not in the diff are acceptable — confirm via `git diff --unified=0 ... | grep '^+' | awk 'length>120'` returning nothing).
- [ ] **Step 3: Commit any lint fixes** (only if Step 2 surfaced issues on changed lines)
```bash
git add -A
git commit -m "chore(positioning): lint/type fixes for coin_gross_cap"
```
---
## Post-merge deploy + backfill (OPERATOR steps — outside the coding plan, run after merge)
These are runtime/prod steps, NOT code, listed so the operator has the full picture. Do them after the branch is merged to master and pushed:
1. **Deploy the code:** `git push origin master` (the Dagster K8sRunLauncher git-syncs master HEAD; a code-only change needs no image rebuild). Rollout-restart the dagster daemon if it caches code: `kubectl --context=$BIZ -n foxhunt rollout restart deploy/dagster-daemon`.
2. **Backfill the anchor:** the re-inception is AUTOMATIC on the next `combined_book_daily` run (23:30 UTC) — the bumped version + backdated `inception_t0` cause `forward_engine` to reset the anchor to `t0=2026-07-06` and recompute the forward window with the capped construction. Do NOT run `migrate-forward-anchors` (re-inception is automatic — see `reference_fxhnt_prod_deploy_mechanics`).
3. **Refresh the backtest ref:** the `bybit_book_precompute` asset re-persists the `positioning` / `bybit_4edge` / `bybit_4edge_levered` `backtest_summary` from the capped `_persist_bybit_book` in the same nightly run, so the gate reconciles the forward track against the capped (capturable) reference.
4. **Verify next morning:** query the prod DB (port-forward svc/postgres) — `positioning` forward should re-show ~13 days (recomputed, capped), the 2026-07-06/07 daily returns should be materially less negative than 7.32%/6.89%, and the gate should read WAIT "building" (not reset to 0) or its real verdict.
---
## Self-Review
**1. Spec coverage:** cap in weights (T1) → threaded through book (T2) → sleeve seam (T3) → enabled live (T4) → re-inception+backfill (T5) → verified (T6). The "backfill not reset" constraint is implemented via `inception_t0` backdating in T5 + operator step 2. Covered.
**2. Placeholder scan:** every code step shows the exact code; the only deferred item is the shock-store fixture in T2/T3 which is explicitly instructed to reuse the existing `test_capacity_haircut_*` store construction in the same file (a real, named pattern, not a placeholder). Acceptable — the implementer has a concrete reference.
**3. Type consistency:** `coin_gross_cap: float | None = None` is identical across `positioning_weights`, `_book_breakdown`, `positioning_returns_from_store`, `_positioning_series`, `sleeve_returns_from_store`. Constant `POSITIONING_COIN_GROSS_CAP = 0.07` in `bybit_forward_track.py`, imported in `bybit_book_persist.py`. Versions all → 3, `inception_t0.date` all → "2026-07-06". Consistent.
---
### Task 7: Thread `coin_gross_cap` into the LIVE forward-nav path (Critical fix from final review)
**Why:** The final whole-branch review found the cap threads to the reconciliation REF (via `_persist_bybit_book`) but NOT to the live FORWARD NAV (built by `BybitFourEdgeStrategy`), so the gate would compare a capped ref against an uncapped forward and read a healthy book as "diverging below backtest" (reproduced: forward 25% vs ref 12.5% on a LABUSDT shock). Fail-safe (WAIT, never false-PASS) but defeats the branch's purpose. This threads the cap the same way `positioning_tail_cap_k` is already threaded.
**Files:**
- Modify: `src/fxhnt/application/bybit_forward_track.py` (`BybitFourEdgeStrategy.__init__` ~135-157, `advance` ~168-172)
- Modify: `src/fxhnt/adapters/orchestration/assets.py` (builders at 105-122, 128-143, 149-158, 164-169; nightly call sites 868-876, 916-924, 957-965)
- Modify: `src/fxhnt/adapters/orchestration/migration_builders.py` (builders at 106-117, 127-141, 148-156)
- Modify: `src/fxhnt/application/bybit_positioning_eval.py:280` (`positioning_weights_and_contributions` — the Important SSOT-attribution fix)
- Test: `tests/integration/test_positioning_forward_track.py` (+ the attribution invariant in `tests/integration/test_bybit_paper_reconciliation.py`)
**Interfaces:**
- Consumes: `POSITIONING_COIN_GROSS_CAP = 0.07` (Task 4), `sleeve_returns_from_store(..., coin_gross_cap=...)` (Task 3), `positioning_weights(..., coin_gross_cap=...)` (Task 1).
- Produces: `BybitFourEdgeStrategy(..., positioning_coin_gross_cap: float | None = None, ...)`; the four `assets.py` builders + three `migration_builders.py` builders gain a `positioning_coin_gross_cap` param forwarded to the strategy; the three nightly call sites pass `POSITIONING_COIN_GROSS_CAP`.
- [ ] **Step 1: Write the failing test**
Add to `tests/integration/test_positioning_forward_track.py` — a shock store where the forward NAV's worst day is materially less negative when the cap is threaded (mirror the shock-store construction already used in the positioning tests):
```python
def test_forward_nav_applies_coin_gross_cap() -> None:
"""The forward NAV (BybitFourEdgeStrategy store-read path) must apply the per-coin cap when given, so it
reconciles against the capped ref instead of diverging. Shock store: one over-short coin craters ~50%."""
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_forward_track import BybitFourEdgeStrategy
_DAY = 86_400
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
px = {"LABUSDT": 100.0, "AAAUSDT": 100.0, "BBBUSDT": 100.0, "CCCUSDT": 100.0}
for d in range(4):
store.write_features("LABUSDT", [(d * _DAY, {"long_ratio": 0.05, "close": px["LABUSDT"]})])
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.50, "close": px["AAAUSDT"]})])
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.48, "close": px["BBBUSDT"]})])
store.write_features("CCCUSDT", [(d * _DAY, {"long_ratio": 0.52, "close": px["CCCUSDT"]})])
if d == 2:
px["LABUSDT"] *= 0.50
uncapped_rows, _ = BybitFourEdgeStrategy(store, sleeves=["positioning"]).advance(None, {})
capped_rows, _ = BybitFourEdgeStrategy(
store, sleeves=["positioning"], positioning_coin_gross_cap=0.07).advance(None, {})
store.close()
assert uncapped_rows and capped_rows, "forward strategy produced no rows — fixture broken"
u = dict(uncapped_rows); c = dict(capped_rows)
worst = min(u, key=lambda k: u[k])
assert c[worst] > u[worst], f"cap must reduce the forward worst day: uncapped={u[worst]}, capped={c[worst]}"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_positioning_forward_track.py::test_forward_nav_applies_coin_gross_cap -v`
Expected: FAIL with `TypeError: BybitFourEdgeStrategy.__init__() got an unexpected keyword argument 'positioning_coin_gross_cap'`
- [ ] **Step 3: Add the param to `BybitFourEdgeStrategy` and thread it in `advance`**
In `__init__` (after `positioning_tail_cap_k: float | None = None,` on line 142) add:
```python
positioning_coin_gross_cap: float | None = None,
```
And in the body (after `self._positioning_tail_cap_k = positioning_tail_cap_k` line 154):
```python
self._positioning_coin_gross_cap = positioning_coin_gross_cap
```
In `advance`'s store-read `sleeve_returns_from_store(...)` call (lines 168-172), add the kwarg:
```python
series = sleeve_returns_from_store(
self._store, sleeve, universe=self._universe, cost_bps=self._cost_bps,
unlock_events=self._unlock_events, stablecoin_spot_panel=self._stablecoin_spot_panel,
capacity_capital=self._positioning_capacity_capital,
tail_cap_k=self._positioning_tail_cap_k,
coin_gross_cap=self._positioning_coin_gross_cap)
```
- [ ] **Step 4: Thread through every builder (parallel to `positioning_tail_cap_k`)**
In `src/fxhnt/adapters/orchestration/assets.py`, for EACH of the four builders (`build_bybit_4edge_forward_nav`, `build_bybit_4edge_levered_forward_nav`, and the two single-sleeve builders at 149 and 164): add a `positioning_coin_gross_cap=None` param to the signature (next to `positioning_tail_cap_k=None`) and forward `positioning_coin_gross_cap=positioning_coin_gross_cap` into the `BybitFourEdgeStrategy(...)` call. For the THREE nightly call sites (868-876, 916-924, 957-965) that already pass `positioning_tail_cap_k=POSITIONING_TAIL_CAP_K`: add `positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` (import it alongside `POSITIONING_TAIL_CAP_K`).
Do the identical change in `src/fxhnt/adapters/orchestration/migration_builders.py` for its three builders that pass `positioning_tail_cap_k=POSITIONING_TAIL_CAP_K` (106-117, 127-141, 148-156): add `positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` to each `BybitFourEdgeStrategy(...)` call, importing the constant.
- [ ] **Step 5: Fix the attribution SSOT (the Important finding)**
In `src/fxhnt/application/bybit_positioning_eval.py`, `positioning_weights_and_contributions` (line 263): add `coin_gross_cap: float | None = None` to its signature and forward it in the `positioning_weights(...)` call at line 280:
```python
weights_by_day = positioning_weights(_ratio_by_day(long_ratio), direction=direction,
coin_gross_cap=coin_gross_cap)
```
Then thread it through `sleeve_weights_and_contributions` in `src/fxhnt/application/bybit_paper_weights.py` (the caller at ~80-84) so the paper-book per-symbol weights match the capped sleeve return (preserving the `Σ weight·return == sleeve_returns_from_store` invariant documented at `bybit_paper_weights.py:9`). Default None everywhere.
- [ ] **Step 6: Run the tests + verify no regression**
Run: `uv run --extra dev python -m pytest tests/integration/test_positioning_forward_track.py tests/integration/test_bybit_paper_reconciliation.py tests/integration/test_bybit_positioning_eval.py -v`
Expected: all pass (new forward-nav cap test passes; existing reconciliation invariant test still passes with default-None on both sides).
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "fix(positioning): thread coin_gross_cap into the LIVE forward-nav path + attribution (final-review Critical)"
```
---
### Task 8: Cap the LIVE paper-book POSITIONS + the report-ref path (Critical + Important from Task-7 review)
**Why:** The Task-7 review traced two more uncapped paths:
- **Critical:** the live paper-book position sizing (`combined_symbol_weights*` / `latest_raw_sleeve_weights``derive_and_persist_bybit_paper_book` → the nightly `bybit_paper_book` asset) never passes the positioning haircuts, so the fund's ACTUAL persisted positions for the positioning sleeve are sized uncapped even though the reconciled return is capped. It turns out capacity + tail_cap were ALSO never threaded here (pre-existing gap). Operator decision (2026-07-20): thread ALL THREE haircuts so executed positions match the reconciled returns (full SSOT).
- **Important:** `bybit_4edge_backtest_summary` + its caller `report_backtest_summary` pass `positioning_tail_cap_k` but no `positioning_coin_gross_cap` — a real, independently-callable uncapped ref path (masked in the scheduled flow only by the `_BYBIT_BOOK_SIDS` special-case).
**Files:**
- Modify: `src/fxhnt/application/bybit_paper_book.py``combined_symbol_weights` (~48), `combined_symbol_weights_and_returns` (~105), `latest_raw_sleeve_weights` (~80), and `derive_and_persist_bybit_paper_book` (the persist driver): add `positioning_capacity_capital`, `positioning_tail_cap_k`, `positioning_coin_gross_cap` (all `float | None = None`) and forward into every `sleeve_weights_and_contributions(...)` call.
- Modify: `src/fxhnt/adapters/orchestration/assets.py``build_bybit_paper_book` (~180): add the 3 params, forward into `derive_and_persist_bybit_paper_book`; nightly call site (~1105): pass `positioning_capacity_capital=s.paper_capital, positioning_tail_cap_k=POSITIONING_TAIL_CAP_K, positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` (import the two constants).
- Modify: `src/fxhnt/application/bybit_forward_track.py``bybit_4edge_backtest_summary` (~210): add `positioning_coin_gross_cap: float | None = None` next to `positioning_tail_cap_k`, forward into the `BybitFourEdgeStrategy(...)` call.
- Modify: `src/fxhnt/application/backtest_refs_report.py``report_backtest_summary` (~42): import `POSITIONING_COIN_GROSS_CAP`, pass `positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` into `bybit_4edge_backtest_summary(...)`.
- Test: `tests/integration/test_bybit_paper_reconciliation.py` (or the paper-book test file — grep for a test that calls `combined_symbol_weights_and_returns` / `derive_and_persist_bybit_paper_book`).
**Interfaces:**
- Consumes: `sleeve_weights_and_contributions(..., positioning_capacity_capital=None, positioning_tail_cap_k=None, positioning_coin_gross_cap=None)` (Task 7 added coin_gross_cap; VERIFY it also has capacity/tail params — if `sleeve_weights_and_contributions` does NOT yet accept capacity_capital/tail_cap_k, add them there too, forwarding into its `positioning_weights`/underlying call, same as coin_gross_cap).
- Produces: the 3 haircut params on `combined_symbol_weights`, `combined_symbol_weights_and_returns`, `latest_raw_sleeve_weights`, `derive_and_persist_bybit_paper_book`, `build_bybit_paper_book`; `positioning_coin_gross_cap` on `bybit_4edge_backtest_summary` + `report_backtest_summary`. All default None.
- [ ] **Step 1: Write the failing test**
Add a paper-book position test: on a LABUSDT-shock store, the positioning sleeve's persisted per-symbol weight for the crashing coin is smaller when `positioning_coin_gross_cap=0.07` is threaded through `combined_symbol_weights_and_returns` than without.
```python
def test_paper_book_positions_capped_by_coin_gross_cap() -> None:
"""The live paper-book per-symbol weights must honor the per-coin cap so executed positions match the
capped reconciled return (SSOT). On a store with one dominant positioning coin, the capped weight for that
coin is strictly smaller than uncapped."""
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_paper_book import combined_symbol_weights_and_returns
_DAY = 86_400
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
for d in range(4):
store.write_features("LABUSDT", [(d * _DAY, {"long_ratio": 0.02, "close": 100.0})])
for i, sym in enumerate(("AAAUSDT", "BBBUSDT", "CCCUSDT", "DDDUSDT")):
store.write_features(sym, [(d * _DAY, {"long_ratio": 0.45 + 0.02 * i, "close": 100.0})])
w_unc, _r, _d = combined_symbol_weights_and_returns(store, sleeves=("positioning",))
w_cap, _r2, _d2 = combined_symbol_weights_and_returns(
store, sleeves=("positioning",), positioning_coin_gross_cap=0.07)
store.close()
assert w_unc.get("positioning") and w_cap.get("positioning"), "no positioning weights — fixture broken"
lab_unc = abs(w_unc["positioning"].get("LABUSDT", 0.0))
lab_cap = abs(w_cap["positioning"].get("LABUSDT", 0.0))
assert lab_unc > 0.0
assert lab_cap < lab_unc, f"cap must shrink the dominant coin's position: unc={lab_unc}, cap={lab_cap}"
```
- [ ] **Step 2: Run to verify it fails**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_paper_reconciliation.py::test_paper_book_positions_capped_by_coin_gross_cap -v`
Expected: FAIL with `TypeError: combined_symbol_weights_and_returns() got an unexpected keyword argument 'positioning_coin_gross_cap'`
- [ ] **Step 3: Thread the 3 haircuts through the paper-book weight functions**
For `combined_symbol_weights`, `combined_symbol_weights_and_returns`, and `latest_raw_sleeve_weights` in `bybit_paper_book.py`: add `positioning_capacity_capital: float | None = None`, `positioning_tail_cap_k: float | None = None`, `positioning_coin_gross_cap: float | None = None` to each signature, and forward all three into their `sleeve_weights_and_contributions(...)` call(s) (as `capacity_capital=`/`tail_cap_k=`/`positioning_coin_gross_cap=` per that function's actual param names — grep `def sleeve_weights_and_contributions` for the exact names). If `sleeve_weights_and_contributions` lacks capacity_capital/tail_cap_k params, add them there first (forwarding into its positioning path), then here.
- [ ] **Step 4: Thread through the persist driver + builder**
`derive_and_persist_bybit_paper_book` (bybit_paper_book.py): add the 3 params, forward into its `combined_symbol_weights_and_returns(...)` call. `build_bybit_paper_book` (assets.py:180): add the 3 params, forward into `derive_and_persist_bybit_paper_book(...)`. Nightly call site (assets.py:~1105): pass `positioning_capacity_capital=s.paper_capital, positioning_tail_cap_k=POSITIONING_TAIL_CAP_K, positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` (import both constants from `bybit_forward_track`).
- [ ] **Step 5: Fix the report-ref path (Important)**
`bybit_4edge_backtest_summary` (bybit_forward_track.py:~210): add `positioning_coin_gross_cap: float | None = None` next to `positioning_tail_cap_k`, forward into `BybitFourEdgeStrategy(..., positioning_coin_gross_cap=positioning_coin_gross_cap)`. `report_backtest_summary` (backtest_refs_report.py:~42): import `POSITIONING_COIN_GROSS_CAP`, pass `positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP` into the `bybit_4edge_backtest_summary(...)` call.
- [ ] **Step 6: Run tests + no regression**
Run: `uv run --extra dev python -m pytest tests/integration/test_bybit_paper_reconciliation.py tests/integration/test_positioning_forward_track.py tests/unit/test_backtest_refs_report.py tests/integration/test_bybit_positioning_eval.py -v`
Expected: all pass. Then confirm no site was missed: `grep -c positioning_tail_cap_k` vs `grep -c positioning_coin_gross_cap` should MATCH in bybit_paper_book.py, assets.py, bybit_forward_track.py, backtest_refs_report.py.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "fix(positioning): cap live paper-book positions + report-ref path (Task-7-review Critical+Important)"
```

View File

@@ -0,0 +1,85 @@
# EUR Fund Ledger — Design Spec (2026-07-20)
**Status:** design approved (scope + cost-basis decided with operator). Implementation plan follows separately.
**> NOT tax advice.** This is an engineering design for a bookkeeping system. The tax *treatment* it records
(Box 3 passive capital, FIFO cost basis, EUR-functional) reflects the operator's resolved structure and must be
confirmed by a Dutch belastingadviseur before it is relied on for a filing. See
`docs/superpowers/2026-07-20-fund-tax-accounting-research-brief.md` and memory
`project_fxhnt_fund_tax_accounting_research`.
## Goal
A tax-ready, append-only, double-entry **EUR ledger** for the fxhnt fund's **passive Box-3 investment book**,
living in the existing Postgres/Timescale warehouse, fed by an automated **IBKR Flex** ingestion Dagster asset.
Phase 1 books **the fund only** — the passive IBKR investment book. The tech-BV bookkeeping and the arm's-length
BV→fund service stay in the operator's existing BV administration (explicitly out of scope).
## Why this shape (context)
- The operator's structure separates **arbeid** (tech-BV, taxed there) from **kapitaal** (private fund, Box 3).
Phase 1 books only the kapitaal side → a clean Box-3 record with no BV entanglement.
- **EUR is the functional currency**, not just presentation. IBKR DU9600528 is EUR-native; if crypto ever goes
live it is Bybit-EU (EUR, not USDT). There is **no USD leg**, so there is **no conversion pipeline** — only a
dormant `currency` + `fx_rate_to_eur` seam (default EUR/1.0) so a stray non-EUR line is representable and
correct rather than silently mis-booked. A pure-EUR book has **no FX P&L** — only trading P&L.
- Reuses existing patterns: SQLAlchemy 2.0 ORM (`Base`/`Mapped`, Postgres+SQLite), a `*Repo` with `migrate()`,
a Dagster asset alongside the nightly ETL. No new infrastructure.
## Global Constraints
1. **EUR-functional.** Every posting stored in EUR. `currency` defaults `"EUR"`, `fx_rate_to_eur` defaults
`1.0`. Build NO conversion pipeline — the FX columns are dormant insurance only.
2. **Append-only.** `journal_entries` / `journal_postings` are immutable. Corrections are reversing entries
(never UPDATE/DELETE of a posted row). Ingestion is idempotent (re-running a Flex pull must not double-book).
3. **Balanced in EUR.** A DB-level trigger (or repo-enforced invariant on both Postgres and SQLite) rejects any
entry whose postings do not sum to zero in `eur_amount`.
4. **FIFO** cost basis, applied consistently. Realized gains come from lot-matching, not cash-flow deltas.
5. **Scope = fund only.** No tech-BV, dividend, or arm's-length-service postings in Phase 1.
6. **Runs on paper now.** The IBKR Flex asset must run against the *paper* account (DU9600528) — the schema and
the EUR-sum-to-zero invariant are validated on paper data before any real capital exists.
7. **Same test discipline as the codebase:** models work on SQLite (unit tests) and Postgres (prod); full suite
green before finishing.
## Schema (SQLAlchemy 2.0 ORM, in a new `ledger` module)
- **`accounts`** — chart of accounts. `account_id` (str PK, e.g. `IBKR-EUR-cash`, `IBKR-securities`,
`contributed-capital`, `trading-pnl`, `fees`), `kind` (asset|liability|equity|income|expense), `venue`,
`currency` (default EUR). Per-venue sub-ledgers each balance standalone.
- **`journal_entries`** — header. `entry_id` (str PK), `entry_date` (date), `description`, `source_venue`,
`strategy_tag` (nullable — the Dagster attribution dimension), `source_ref` (the idempotency key: the IBKR
Flex trade/cash id, so re-ingest is a no-op). Immutable.
- **`journal_postings`** — lines. `posting_id` (PK), `entry_id` (FK), `account_id` (FK), `side` (debit|credit),
`amount`, `currency` (default EUR), `fx_rate_to_eur` (default 1.0), `eur_amount`. Invariant: per `entry_id`,
Σ `eur_amount`(debit) == Σ `eur_amount`(credit).
- **`cost_basis_lots`** — `lot_id` (PK), `account_id`, `asset`, `open_date`, `qty_open`, `qty_remaining`,
`unit_cost_eur`, and on close a link to the realizing entry. FIFO consumption.
- **`fx_rates`** — `value_date` + `pair``rate`. **Dormant** — populated only if a non-EUR asset appears.
Present so the seam exists; no ingestion job in Phase 1.
## IBKR Flex ingestion (new adapter surface + Dagster asset)
The existing `IbkrBroker` is a live `ib_insync` socket connection — NOT the Flex API. Flex is a separate
**Flex Web Service**: a per-query token + query id, a 2-step `SendRequest``GetStatement` poll (~1 req/sec),
returning an XML statement. Add a read-only `IbkrFlexClient` (new file) that fetches and parses:
trades, cash transactions, dividends, corporate actions, positions, NAV.
- Land raw XML/rows in an append-only `raw` schema (mirrors the DB-anchor-SSOT pattern), then a transform
normalizes into `journal_entries`/`journal_postings` (idempotent on `source_ref`).
- One Dagster asset (`fund_ledger_ingest` or similar) alongside the nightly ETL; reuses monitoring patterns.
- W-8BEN note: as a non-US person the account gets a 1042-S, never a Consolidated 1099 — irrelevant to the
EUR ledger math, but archive the annual Activity Statement as an audit doc.
## Out of scope (Phase 1) — deliberately deferred
- Bybit ingestion (no live crypto book; Bybit-EU is EUR anyway → add later, same schema).
- The tech-BV → fund arm's-length service postings, dividend, capital-contribution accounting (stays in BV admin).
- The Box-3 *werkelijk-rendement* parallel tax-basis calc (unrealized + fee-disallow) — only needed if the
tegenbewijsregeling is elected; add when the advisor confirms it's worth it.
- Any FX conversion pipeline (there is no non-EUR leg).
## First build step
The EUR-functional ledger core (models + repo + the EUR-sum-to-zero invariant) **plus** the IBKR Flex ingestion
asset — validated on the paper account — before anything else. This exercises every load-bearing decision:
append-only immutability, the EUR-balanced invariant, the `strategy_tag` dimension, FIFO lots.