feat(alloc): per-sleeve crypto allocation (promote xsfunding/unlock; bybit_4edge->reference)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user