From 98dda4e835ffbf7d4114dcc02476da62e5d7eaec Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 16 Jul 2026 07:47:26 +0200 Subject: [PATCH] feat(alloc): per-sleeve crypto allocation (promote xsfunding/unlock; bybit_4edge->reference) Co-Authored-By: Claude Opus 4.8 --- .../application/allocation_honest_inputs.py | 43 ++++++---- src/fxhnt/registry.py | 25 ++++-- tests/unit/test_allocation_honest_inputs.py | 83 +++++++++++++++---- tests/unit/test_registry_tiers.py | 13 ++- 4 files changed, 122 insertions(+), 42 deletions(-) diff --git a/src/fxhnt/application/allocation_honest_inputs.py b/src/fxhnt/application/allocation_honest_inputs.py index 1b9fa84..d78ad31 100644 --- a/src/fxhnt/application/allocation_honest_inputs.py +++ b/src/fxhnt/application/allocation_honest_inputs.py @@ -6,14 +6,20 @@ PURE + network-free: `store` is read-only (panel reads via the existing edge run Everything is composed from the existing engines — no return math is reimplemented here: * `sleeve_weights_and_contributions` (per-symbol weights) + `honest_sleeve_returns` (capacity + cost) build each sleeve's honest daily series at a given AUM; - * `risk_parity_weights` + `book_returns` aggregate the crypto BOOK for the `bybit_4edge` deploy strategy; + * `risk_parity_weights` + `book_returns` aggregate the crypto BOOK for the honest-reference REPORT (see + `_book_series` below) — no longer used by the allocation path (see the 2026-07-16 spec §4d); * `sharpe_of` + `capacity_ceiling_from_curve` turn an AUM→Sharpe curve into the honest capacity ceiling. -The deploy→honest mapping (crypto-only today): - * "positioning" → its OWN sleeve series at the AUM; ceiling from its capacity curve. - * "bybit_4edge" → the risk-parity BOOK of its available crypto sleeves (`_BOOK_SLEEVES`, tstrend EXCLUDED - — it has no standalone store series); empty sleeves (e.g. unlock with no events) are - dropped; ceiling from the book's capacity curve. +The deploy→honest mapping (crypto-only today; per-sleeve since the 2026-07-16 spec, which re-points the +crypto allocation at the individual sleeves instead of the double-counting `bybit_4edge` book): + * "positioning" / "xsfunding" / "unlock" (`_CRYPTO_DEPLOY_SIDS`) → that sleeve's OWN series at the AUM + (via `_sleeve_series`); ceiling from its capacity curve. Each sleeve enters the deploy + set independently (gated by its own reconciliation gate / `promote_on_pass` in the + registry, not by this module). + * "bybit_4edge" → NO LONGER an allocation target (demoted to `tier: research`, a tracked reference + aggregate). It falls through to the "any OTHER sid" branch below like any non-crypto + sid; `_book_series` / `_BOOK_SLEEVES` are KEPT for the honest-reference REPORT (a + separate consumer), just no longer dispatched to from the allocation path. * any OTHER sid → the equity default ceiling, NO series (the caller falls back to its own reference for equity strategies; we do NOT fabricate a crypto series for equity). @@ -22,8 +28,9 @@ but the downstream consumers (`compute_allocation` / `full_history_vol` / `deplo `record_allocation`) key on ISO DATE STRINGS (e.g. "2026-06-01") and cross-align strategies BY KEY — and the nav fallback in `record_allocation` keys on those SAME ISO date strings. So every series is returned with the epoch-day int converted to the SAME ISO date string via `_iso` (the canonical `date(1970,1,1)+timedelta(days)` -conversion) for every strategy, so `deploy_book_returns` aligns positioning, bybit_4edge, AND any nav-fallback -series on identical calendar-day keys (a test asserts positioning & bybit_4edge share a common key).""" +conversion) for every strategy, so `deploy_book_returns` aligns positioning, xsfunding, unlock, AND any +nav-fallback series on identical calendar-day keys (a test asserts positioning & xsfunding share a common +key).""" from __future__ import annotations import datetime as _dt @@ -39,9 +46,11 @@ from fxhnt.application.honest_reference import honest_sleeve_returns # when its event calendar is absent (empty series). Ordered; the book aggregation is order-independent. _BOOK_SLEEVES = ("xsfunding", "unlock", "positioning") -# The deploy strategies with a crypto honest-reference series. Any sid NOT here gets the equity default -# ceiling and no series (the caller supplies the equity reference). -_CRYPTO_DEPLOY_SIDS = frozenset({"positioning", "bybit_4edge"}) +# The deploy strategies with a crypto honest-reference series — the individual sleeves, NOT the `bybit_4edge` +# book (demoted to a research-tier reference aggregate 2026-07-16 to resolve the positioning/book +# double-count; see the module docstring + spec §4d). Any sid NOT here gets the equity default ceiling and +# no series (the caller supplies the equity reference). +_CRYPTO_DEPLOY_SIDS = frozenset({"positioning", "xsfunding", "unlock"}) _EPOCH = _dt.date(1970, 1, 1) @@ -88,14 +97,12 @@ def _deploy_series(store: Any, sid: str, spread_bps: dict[str, float], unlock_events: list[dict[str, Any]] | None, *, aum: float, participation_cap: float, impact_k: float) -> dict[int, float]: """The honest-reference `dict[int, float]` series for a crypto deploy `sid` at ONE `aum` (empty for a - non-crypto sid). `sid` is passed explicitly (not captured from a loop variable) so there is no - lambda-in-loop closure bug — the branch computes the series directly.""" - if sid == "positioning": - return _sleeve_series(store, "positioning", spread_bps, unlock_events, aum=aum, + non-crypto sid, INCLUDING `bybit_4edge` — no longer an allocation target, see `_CRYPTO_DEPLOY_SIDS`). + `sid` is passed explicitly (not captured from a loop variable) so there is no lambda-in-loop closure + bug — the branch computes the series directly.""" + if sid in _CRYPTO_DEPLOY_SIDS: + return _sleeve_series(store, sid, spread_bps, unlock_events, aum=aum, participation_cap=participation_cap, impact_k=impact_k) - if sid == "bybit_4edge": - return _book_series(store, spread_bps, unlock_events, aum=aum, - participation_cap=participation_cap, impact_k=impact_k) return {} diff --git a/src/fxhnt/registry.py b/src/fxhnt/registry.py index aece67b..42be82a 100644 --- a/src/fxhnt/registry.py +++ b/src/fxhnt/registry.py @@ -62,6 +62,10 @@ STRATEGY_REGISTRY: dict[str, dict[str, Any]] = { "display_name": "Crypto funding harvest (cross-sectional, delta-neutral)", "sleeve": "crypto-funding", "venue": "bybit-perp", "tier": "research", "execution": "paper", + # AUTO-PROMOTE research->deploy once the reconciliation gate PASSes — per-sleeve allocation + # (2026-07-16 spec §4d) re-points the crypto allocation at the individual sleeves instead of the + # `bybit_4edge` book, so this sleeve enters the allocation on gate PASS, exactly as `multistrat` does. + "promote_on_pass": True, "gate_spec": {"gate_type": "reconciliation", "min_forward_days": 14}, # cost_bps mirrors Settings.bybit_cost_bps; a cost change requires a definition_version bump # to re-inception (else the hashed audit silently diverges from the cost actually charged). @@ -82,6 +86,9 @@ STRATEGY_REGISTRY: dict[str, dict[str, Any]] = { "display_name": "Token-unlock dilution shorts (market-neutral)", "sleeve": "crypto-event", "venue": "bybit-perp", "tier": "research", "execution": "paper", + # AUTO-PROMOTE research->deploy once the reconciliation gate PASSes — same per-sleeve rationale as + # xsfunding above (2026-07-16 spec §4d). + "promote_on_pass": True, "gate_spec": {"gate_type": "reconciliation", "min_forward_days": 14}, # cost_bps mirrors Settings.bybit_cost_bps; a cost change requires a definition_version bump # to re-inception (else the hashed audit silently diverges from the cost actually charged). @@ -161,15 +168,21 @@ STRATEGY_REGISTRY: dict[str, dict[str, Any]] = { "definition": {"params": {"source": "ibkr-xsp-fills", "models": "vrp"}, "version": 1, "record_mode": "observed", "backtest": {"kind": "model-ref"}}, }, - # Live FORWARD PAPER TRACK for the 4-edge Bybit book — the OUT-OF-SAMPLE record on the venue we'll - # actually trade. Records the NAIVE equal-weight 4-sleeve book (tstrend/unlock/xsfunding/positioning) on - # `bybit_features` — the honest deployable number (the live risk overlay over-levers on Bybit; that is a - # separate re-tune, deliberately NOT this track). RECONCILIATION gate: WAIT ~2 weeks, then PASS iff the - # forward track reconciles with the backtest (does not diverge/contradict it) and executed cleanly. + # Forward PAPER TRACK for the 4-edge Bybit book — the OUT-OF-SAMPLE record on the venue we'll actually + # trade. Records the NAIVE equal-weight 4-sleeve book (tstrend/unlock/xsfunding/positioning) on + # `bybit_features` (the live risk overlay over-levers on Bybit; that is a separate re-tune, deliberately + # NOT this track). RECONCILIATION gate: WAIT ~2 weeks, then PASS iff the forward track reconciles with + # the backtest (does not diverge/contradict it) and executed cleanly. + # DEMOTED to `tier: research` 2026-07-16 (spec `2026-07-16-deploy-fund-config-knobs-design.md` §4d): the + # book double-counted `positioning` (booked both standalone AND as a book constituent), so the crypto + # allocation now runs PER-SLEEVE (`positioning` + any of `xsfunding`/`unlock` whose own gate PASSes — + # see `promote_on_pass` above) instead of over this aggregate. This track stays a tracked REFERENCE + # aggregate alongside its `bybit_4edge_exec` / `bybit_4edge_levered` research twins — no longer an + # allocation target (gate_spec/definition/inception_t0 unchanged; only `tier` moved). "bybit_4edge": { "display_name": "Bybit 4-edge book (naive eq-wt, forward)", "sleeve": "crypto-bybit", "venue": "bybit-perp", - "tier": "deploy", "execution": "paper", + "tier": "research", "execution": "paper", # min_forward_days 21 (not 14): this book's gains arrive in irregular clusters (median ~17 days between # big +days), so a lumpy edge needs a slightly longer window to be representative before the gate rules. "gate_spec": {"gate_type": "reconciliation", "min_forward_days": 21}, diff --git a/tests/unit/test_allocation_honest_inputs.py b/tests/unit/test_allocation_honest_inputs.py index 3c07381..692121c 100644 --- a/tests/unit/test_allocation_honest_inputs.py +++ b/tests/unit/test_allocation_honest_inputs.py @@ -1,16 +1,21 @@ """Task 4 — `honest_allocation_inputs`: the deploy-strategy -> honest-reference series + capacity ceiling provider. Uses an in-memory sqlite `TimescaleFeatureStore` seeded like the fidelity test's fixture (flat cross-sectional funding for xsfunding + a per-coin long_ratio/price wiggle for positioning), so BOTH crypto -sleeves are computable WITHOUT an unlock calendar. Asserts the crypto-only deploy mapping, the int->str -key seam, and that the positioning and bybit_4edge series share a common string key (so `deploy_book_returns` -aligns them).""" +sleeves are computable WITHOUT an unlock calendar. Asserts the crypto-sleeve-only deploy mapping — now the +THREE individual sleeves (positioning/xsfunding/unlock), NOT the `bybit_4edge` book, which per the +2026-07-16 spec (`docs/superpowers/specs/2026-07-16-deploy-fund-config-knobs-design.md` §4d) moved to a +research-tier reference aggregate and is no longer an allocation target — the int->str key seam, and that +positioning and xsfunding series share a common string key (so `deploy_book_returns` can align them).""" from __future__ import annotations import datetime as dt import math from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore -from fxhnt.application.allocation_honest_inputs import honest_allocation_inputs +from fxhnt.application.allocation_honest_inputs import ( + _CRYPTO_DEPLOY_SIDS, + honest_allocation_inputs, +) _DAY = 86_400 # Base the fixture at a REAL calendar date (2026-01-01) so the epoch-day keys convert to ISO dates in the @@ -45,20 +50,43 @@ def _seed(store: TimescaleFeatureStore, *, days: int = 120) -> None: store.write_features(sym, rows) +def _seed_with_unlock(store: TimescaleFeatureStore, *, days: int = 120) -> list[dict]: + """As `_seed`, plus a BTCUSDT hedge instrument (flat/neutral: zero funding, flat basis, so it barely + perturbs the xsfunding cross-section) so the `unlock` sleeve is ALSO computable — `UnlockShortRunner` + needs a BTCUSDT hedge leg (`market="BTCUSDT"`, the default) — and one supply-shock-eligible unlock-cliff + event on AAA (mirrors the shape used by `tests/integration/test_bybit_paper_reconciliation.py`). Returns + the `unlock_events` list the caller passes to `honest_allocation_inputs`.""" + _seed(store, days=days) + price = 50_000.0 + rows = [] + for d in range(days): + price *= 1.0 + 0.0005 * math.sin(0.1 * d) + rows.append(((_BASE_DAY + d) * _DAY, { + "close": price, "spot_close": price, "funding": 0.0, + "high": price, "low": price, "turnover": 1.0e12, + })) + store.write_features("BTCUSDT", rows) + # Cliff near the end of the sample so the 10-day pre-window is inside it; n large enough that the + # supply-shock (n*price / warehouse-qvol-fallback 1e7) clears the runner's min_shock (0.1). + cliff = _BASE_DAY + days - 5 + return [{"sym": "AAA", "cliff_day": cliff, "n": 5_000_000.0, "cat": "insiders"}] + + def test_honest_allocation_inputs_crypto_only_mapping_and_key_seam() -> None: store = TimescaleFeatureStore("sqlite://", table="bybit_features") _seed(store) returns, ceilings = honest_allocation_inputs( store, spread_bps={}, unlock_events=[], - deploy_sids={"positioning", "bybit_4edge", "multistrat"}, + deploy_sids={"positioning", "xsfunding", "bybit_4edge", "multistrat"}, target_aum=35_000, aums=[35_000, 100_000, 350_000, 1_000_000, 10_000_000], ) store.close() - # 1. crypto-only series: multistrat (equity) has NO crypto series. - assert set(returns) == {"positioning", "bybit_4edge"} + # 1. crypto-sleeve-only series: `bybit_4edge` (research-tier reference aggregate, no longer a crypto + # deploy sid) and `multistrat` (equity) get NO series in the allocation path. + assert set(returns) == {"positioning", "xsfunding"} # 2. every series is non-empty and ISO-DATE-STRING-keyed (the epoch-day int -> ISO date seam): each key # contains "-", parses via date.fromisoformat, and falls in the 2020-2027 window (proves the epoch base @@ -70,18 +98,45 @@ def test_honest_allocation_inputs_crypto_only_mapping_and_key_seam() -> None: parsed = dt.date.fromisoformat(k) assert dt.date(2020, 1, 1) <= parsed <= dt.date(2027, 12, 31), f"{sid} key out of window: {k}" - # 3. the equity default ceiling for the non-crypto deploy sid. - assert "multistrat" in ceilings and ceilings["multistrat"] == 10_000_000.0 + # 3. non-crypto sids AND the demoted book both get the equity default ceiling (no honest series to size + # a real ceiling from). + for sid in ("multistrat", "bybit_4edge"): + assert ceilings[sid] == 10_000_000.0 # 4. each crypto sid has a ceiling (a numeric AUM-or-None; the ceiling inherits the AUM key's type, so # with the int aums passed here it is an int — with non-binding capacity the honest Sharpe stays above # min_sharpe at every AUM, so the ceiling is the top of the range). - for sid in ("positioning", "bybit_4edge"): + for sid in ("positioning", "xsfunding"): assert sid in ceilings assert ceilings[sid] is None or isinstance(ceilings[sid], (int, float)) - # 5. KEY ALIGNMENT: positioning and bybit_4edge share >= 1 common string key, so deploy_book_returns - # will cross-align them (the consistent str(epoch_day) conversion across strategies). - common = set(returns["positioning"]) & set(returns["bybit_4edge"]) - assert common, "positioning and bybit_4edge share no common key -> deploy_book_returns cannot align them" + # 5. KEY ALIGNMENT: positioning and xsfunding share >= 1 common string key, so deploy_book_returns + # will cross-align them (the consistent epoch-day -> ISO conversion across strategies). + common = set(returns["positioning"]) & set(returns["xsfunding"]) + assert common, "positioning and xsfunding share no common key -> deploy_book_returns cannot align them" assert all(isinstance(k, str) and dt.date.fromisoformat(k) for k in common) + + +def test_crypto_deploy_sids_are_the_three_sleeves() -> None: + assert _CRYPTO_DEPLOY_SIDS == frozenset({"positioning", "xsfunding", "unlock"}) + + +def test_bybit_4edge_is_not_a_crypto_deploy_sid() -> None: + assert "bybit_4edge" not in _CRYPTO_DEPLOY_SIDS + + +def test_xsfunding_and_unlock_get_a_series_not_equity_default() -> None: + store = TimescaleFeatureStore("sqlite://", table="bybit_features") + events = _seed_with_unlock(store) + + returns, ceilings = honest_allocation_inputs( + store, spread_bps={}, unlock_events=events, + deploy_sids={"xsfunding", "unlock"}, target_aum=35_000.0, aums=[35_000.0, 350_000.0], + ) + store.close() + + assert "xsfunding" in returns and returns["xsfunding"] # sleeve-sized, ISO-keyed + assert "unlock" in returns and returns["unlock"] + # ceilings present (not the equity default 10M sentinel for these crypto sleeves) + assert ceilings["xsfunding"] != 10_000_000.0 + assert ceilings["unlock"] != 10_000_000.0 diff --git a/tests/unit/test_registry_tiers.py b/tests/unit/test_registry_tiers.py index 98cfeae..af620cd 100644 --- a/tests/unit/test_registry_tiers.py +++ b/tests/unit/test_registry_tiers.py @@ -1,11 +1,16 @@ """The registry is the cockpit SSOT: every entry declares a `tier` (deploy|research) and `execution` -(paper|live). The Bybit deploy book + the positioning sleeve are the only `deploy` entries; everything else -is `research`. All trackers are `paper` today (no live execution yet).""" +(paper|live). `positioning` is the only `deploy` entry; everything else is `research`. All trackers are +`paper` today (no live execution yet). + +2026-07-16 (spec `2026-07-16-deploy-fund-config-knobs-design.md` §4d, Task 4): `bybit_4edge` moved +deploy->research (a tracked reference aggregate, no longer an allocation target — it double-counted +`positioning`). The crypto allocation now runs per-sleeve: `positioning` + any of `xsfunding`/`unlock` whose +own reconciliation gate PASSes (`promote_on_pass`).""" from __future__ import annotations from fxhnt.registry import STRATEGY_REGISTRY -_DEPLOY = {"bybit_4edge", "positioning"} +_DEPLOY = {"positioning"} def test_every_entry_has_tier_and_execution() -> None: @@ -14,7 +19,7 @@ def test_every_entry_has_tier_and_execution() -> None: assert e.get("execution") in {"paper", "live"}, f"{sid} has no valid execution" -def test_deploy_tier_is_exactly_the_bybit_book_and_positioning() -> None: +def test_deploy_tier_is_exactly_positioning() -> None: deploy = {sid for sid, e in STRATEGY_REGISTRY.items() if e["tier"] == "deploy"} assert deploy == _DEPLOY