fix(positioning): cap live paper-book positions + report-ref path (coin_gross_cap; Task-7-review Critical+Important)

Threads positioning_coin_gross_cap through the two remaining uncapped
paths found by the Task-7 whole-branch review:

- Critical: combined_symbol_weights / combined_symbol_weights_and_returns /
  latest_raw_sleeve_weights / derive_and_persist_bybit_paper_book /
  build_bybit_paper_book now accept positioning_coin_gross_cap and forward
  it into sleeve_weights_and_contributions, so the live Bybit paper-book's
  ACTUAL persisted positions honor the same per-coin cap as the reconciled
  return. The nightly bybit_paper_book asset now passes
  POSITIONING_COIN_GROSS_CAP.
- Important: bybit_4edge_backtest_summary gained a positioning_coin_gross_cap
  sibling next to positioning_tail_cap_k, forwarded into
  BybitFourEdgeStrategy; report_backtest_summary now passes
  POSITIONING_COIN_GROSS_CAP alongside POSITIONING_TAIL_CAP_K.

Scope: coin_gross_cap only (the EX-ANTE weight cap) — capacity_capital and
tail_cap_k are gains-only return haircuts and do not belong on the weight
path. Default None everywhere (OFF = byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:46:21 +00:00
parent 5d4ddcfb39
commit fb4cb49b18
5 changed files with 76 additions and 21 deletions

View File

@@ -178,7 +178,7 @@ def build_positioning_forward_nav(repo, store, *, today, at, universe=None, cost
def build_bybit_paper_book(repo, store, *, capital, at, universe=None, unlock_events=None,
enabled=True): # type: ignore[no-untyped-def]
enabled=True, positioning_coin_gross_cap=None): # type: ignore[no-untyped-def]
"""Plain (Dagster-free, unit-testable) driver for the Bybit LIVE paper book: derive the naive-eq-wt
4-edge book's per-symbol positions on the latest Bybit prices and persist them (positions/trades/nav)
via `derive_and_persist_bybit_paper_book` into `repo` (a `PaperRepo` — bybit is its sole venue).
@@ -187,14 +187,19 @@ def build_bybit_paper_book(repo, store, *, capital, at, universe=None, unlock_ev
Charges the per-COIN MEASURED cost (real L1 spread from `bybit_real_spread`, taker_fee + 0.5·spread) so the
forward `/paper` step ties to the cockpit Backtest NET; falls back to the flat 5.5bp when no spread refresh
has run."""
has run.
`positioning_coin_gross_cap` (positioning sleeve ONLY) forwards into `derive_and_persist_bybit_paper_book`
so the LIVE persisted positions honor the same per-coin cap the reconciled return uses. Default None
preserves the byte-identical uncapped behaviour; the nightly asset passes `POSITIONING_COIN_GROSS_CAP`."""
from fxhnt.application.bybit_paper_book import derive_and_persist_bybit_paper_book
from fxhnt.application.bybit_spread_refresh import read_real_spread_by_coin
real_spread = read_real_spread_by_coin(repo) or None
n = derive_and_persist_bybit_paper_book(
repo, store, capital=capital, at=at, universe=universe,
unlock_events=unlock_events, enabled=enabled, real_spread_by_coin=real_spread)
unlock_events=unlock_events, enabled=enabled, real_spread_by_coin=real_spread,
positioning_coin_gross_cap=positioning_coin_gross_cap)
# PROACTIVE: re-materialize the headline nav-summary now the bybit nav changed, so the first / + /paper
# request is already fast (read 1 row, not the full nav). The read path self-heals if this is skipped, so
# a refresh failure must NEVER fail the book write — guarded.
@@ -1088,6 +1093,7 @@ def bybit_paper_book(context: AssetExecutionContext) -> dict: # type: ignore[ty
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_forward_track import POSITIONING_COIN_GROSS_CAP
from fxhnt.application.bybit_liquidity import liquid_universe
from fxhnt.application.unlock_calendar_loader import load_unlock_events, operational_unlock_repo
from fxhnt.config import get_settings
@@ -1104,7 +1110,8 @@ def bybit_paper_book(context: AssetExecutionContext) -> dict: # type: ignore[ty
unlock_events = load_unlock_events(operational_unlock_repo(s), f"{_data_dir()}/unlock_calendar.json")
n = build_bybit_paper_book(
repo, store, capital=s.paper_capital, at=dt.datetime.now(dt.UTC),
universe=universe, unlock_events=unlock_events, enabled=True)
universe=universe, unlock_events=unlock_events, enabled=True,
positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP)
context.log.info(f"bybit_paper_book: persisted {n} open positions (venue=bybit)")
return {"positions": n, "enabled": True}
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e: # transient: don't kill the run

View File

@@ -2,6 +2,7 @@ import logging
import os
from fxhnt.application.bybit_forward_track import (
POSITIONING_COIN_GROSS_CAP,
POSITIONING_TAIL_CAP_K,
bybit_4edge_backtest_summary,
bybit_report_sid_kwargs,
@@ -25,10 +26,10 @@ _REPORT_SIDS = ("bybit_4edge", "bybit_4edge_levered", "positioning")
def report_backtest_summary(strategy_id, store): # -> BacktestSummary | None
"""Backtest ref for a `report`-kind bybit strategy: dispatch to the real book engine
`bybit_4edge_backtest_summary` with the per-sid kwargs. Reconstructs the liquid universe, unlock
calendar, and positioning capturability haircuts internally (from the store + settings) so the ref
reconciles against the SAME capturable book the forward track records. cost_bps=5.5 matches the live
BybitFourEdge basis and the `sleeve` kind. Built from the store (sleeve_returns=None) so the
positioning haircut kwargs are applied by the engine."""
calendar, and positioning capturability haircuts + per-coin gross cap internally (from the store +
settings) so the ref reconciles against the SAME capturable book the forward track records. cost_bps=5.5
matches the live BybitFourEdge basis and the `sleeve` kind. Built from the store (sleeve_returns=None) so
the positioning haircut + coin_gross_cap kwargs are applied by the engine."""
if strategy_id not in _REPORT_SIDS:
raise ValueError(f"report_backtest_summary: {strategy_id!r} is not a report-kind sid {_REPORT_SIDS}")
settings = get_settings()
@@ -42,7 +43,8 @@ def report_backtest_summary(strategy_id, store): # -> BacktestSummary | None
common = dict(
universe=universe, cost_bps=5.5,
positioning_capacity_capital=settings.paper_capital,
positioning_tail_cap_k=POSITIONING_TAIL_CAP_K)
positioning_tail_cap_k=POSITIONING_TAIL_CAP_K,
positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP)
# unlock_events is always passed: the positioning sleeve construction (both here and in
# `_persist_bybit_book`) ignores it entirely, so unifying to always-pass is a no-op for that sid — see
# `bybit_report_sid_kwargs`.

View File

@@ -216,6 +216,7 @@ def bybit_4edge_backtest_summary(store: _FeatureStore, *, universe: set[str] | N
strategy_id: str = "bybit_4edge",
positioning_capacity_capital: float | None = None,
positioning_tail_cap_k: float | None = None,
positioning_coin_gross_cap: float | None = None,
sleeve_returns: dict[str, dict[int, float]] | None = None): # type: ignore[no-untyped-def]
"""Backtest reference for the bybit_4edge RECONCILIATION gate: the full-history metrics of the SAME naive
eq-wt funding-net 4-edge book the forward track records (`BybitFourEdgeStrategy`). Persisted under
@@ -224,15 +225,18 @@ def bybit_4edge_backtest_summary(store: _FeatureStore, *, universe: set[str] | N
(a false-PASS risk on a real-capital gate). cagr/vol/sharpe/maxDD from the full series; the gauntlet
verdict (dsr/is/oos/pvalue) from a 60/40 IS/OOS split, mirroring the equity-factor backtest_summary rows.
`positioning_capacity_capital`/`positioning_tail_cap_k` apply the positioning capturability haircuts (only
when this builds from the store, i.e. `sleeve_returns` is None; a precomputed `sleeve_returns` must already
have them baked in — the persist Job computes the positioning series with them)."""
`positioning_capacity_capital`/`positioning_tail_cap_k` apply the positioning capturability haircuts;
`positioning_coin_gross_cap` applies the per-coin EX-ANTE weight cap (shapes the position, not a return
haircut) — all three only take effect when this builds from the store, i.e. `sleeve_returns` is None; a
precomputed `sleeve_returns` must already have them baked in — the persist Job computes the positioning
series with them)."""
rows, _ = BybitFourEdgeStrategy(
store, universe=universe, sleeves=sleeves, cost_bps=cost_bps,
unlock_events=unlock_events, stablecoin_spot_panel=stablecoin_spot_panel,
leverage_shape=leverage_shape, max_gross=max_gross,
positioning_capacity_capital=positioning_capacity_capital,
positioning_tail_cap_k=positioning_tail_cap_k,
positioning_coin_gross_cap=positioning_coin_gross_cap,
sleeve_returns=sleeve_returns).advance(None, {})
return backtest_summary_from_rows(strategy_id, rows)

View File

@@ -49,6 +49,7 @@ def combined_symbol_weights(store: Any, *, universe: set[str] | None = None,
unlock_events: list[dict[str, Any]] | None = None,
sleeves: tuple[str, ...] = _DEFAULT_BYBIT_SLEEVES,
as_of_day: int | None = None,
positioning_coin_gross_cap: float | None = None,
) -> tuple[dict[str, dict[str, float]], int | None]:
"""`({sleeve: {symbol: combined_weight}}, as_of_day)` for the LATEST day of each sleeve, combined NAIVE
EQUAL-WEIGHT (1/n per sleeve). `combined_weight = (1/n)·sleeve_weight` where `sleeve_weight` is the
@@ -57,7 +58,9 @@ def combined_symbol_weights(store: Any, *, universe: set[str] | None = None,
Each sleeve's weights are taken on ITS OWN latest available day (the engines emit different last days);
the returned `as_of_day` is the max latest day across the sleeves (the book's run_date). A sleeve with no
weights (no data / uncomputable, e.g. unlock with no calendar) is simply absent. `as_of_day` (epoch day)
can be pinned for a deterministic backfill day. Pure given the store; READ-ONLY."""
can be pinned for a deterministic backfill day. `positioning_coin_gross_cap` (positioning sleeve ONLY) is
forwarded straight into `sleeve_weights_and_contributions` — default None preserves the byte-identical
uncapped behaviour. Pure given the store; READ-ONLY."""
n = len(sleeves)
if n == 0:
return {}, None
@@ -65,7 +68,8 @@ def combined_symbol_weights(store: Any, *, universe: set[str] | None = None,
latest_day: int | None = None
for sleeve in sleeves:
by_day = sleeve_weights_and_contributions(store, sleeve, universe=universe,
unlock_events=unlock_events)
unlock_events=unlock_events,
positioning_coin_gross_cap=positioning_coin_gross_cap)
days = [d for d in by_day if as_of_day is None or d <= as_of_day]
if not days:
continue
@@ -79,7 +83,8 @@ def combined_symbol_weights(store: Any, *, universe: set[str] | None = None,
def latest_raw_sleeve_weights(store: Any, *, universe: set[str] | None = None,
unlock_events: list[dict[str, Any]] | None = None,
sleeves: tuple[str, ...] = _DEFAULT_BYBIT_SLEEVES,
as_of_day: int | None = None) -> dict[str, float]:
as_of_day: int | None = None,
positioning_coin_gross_cap: float | None = None) -> dict[str, float]:
"""`{symbol: raw_target_weight}` for the Bybit 4-edge book at its LATEST as_of, BEFORE any risk overlay —
the source the real-paper / testnet execution leg places orders against.
@@ -92,9 +97,11 @@ def latest_raw_sleeve_weights(store: Any, *, universe: set[str] | None = None,
Pure + READ-ONLY against the warehouse `store` (writes nothing); memory-bounded on the liquid `universe`.
Net-zero symbols (a long sleeve cancelled by a short sleeve) are dropped. `as_of_day` (epoch day) pins a
deterministic day; default = each sleeve's own latest. The companion as_of day is available via
`combined_symbol_weights` when the caller needs it (e.g. an idempotency marker)."""
`combined_symbol_weights` when the caller needs it (e.g. an idempotency marker). `positioning_coin_gross_cap`
forwards into `combined_symbol_weights` — default None preserves the byte-identical uncapped behaviour."""
combined, _day = combined_symbol_weights(
store, universe=universe, unlock_events=unlock_events, sleeves=sleeves, as_of_day=as_of_day)
store, universe=universe, unlock_events=unlock_events, sleeves=sleeves, as_of_day=as_of_day,
positioning_coin_gross_cap=positioning_coin_gross_cap)
out: dict[str, float] = {}
for by_symbol in combined.values():
for sym, w in by_symbol.items():
@@ -107,12 +114,16 @@ def combined_symbol_weights_and_returns(
unlock_events: list[dict[str, Any]] | None = None,
sleeves: tuple[str, ...] = _DEFAULT_BYBIT_SLEEVES,
as_of_day: int | None = None,
positioning_coin_gross_cap: float | None = None,
) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], int | None]:
"""`(combined_weights, returns, as_of_day)` — like `combined_symbol_weights` but ALSO surfaces each
sleeve's TRUE per-symbol return on its OWN latest (bundled) day: `returns[sleeve][symbol]` is the `r` in
the `(weight, return)` pair (carry = basis + FUNDING, unlock = short_leg_pnl incl. hedge + squeeze,
trend/positioning = price). Fed to `persist_paper_book`'s return-defined book so the live forward stepper
books the TRUE edge return (no naked perp price-MTM, no dropped funding/hedge). Pure; READ-ONLY."""
books the TRUE edge return (no naked perp price-MTM, no dropped funding/hedge). `positioning_coin_gross_cap`
(positioning sleeve ONLY) forwards into `sleeve_weights_and_contributions` so the LIVE paper-book positions
honor the same per-coin cap the reconciled return uses — default None preserves the byte-identical
uncapped behaviour. Pure; READ-ONLY."""
n = len(sleeves)
if n == 0:
return {}, {}, None
@@ -121,7 +132,8 @@ def combined_symbol_weights_and_returns(
latest_day: int | None = None
for sleeve in sleeves:
by_day = sleeve_weights_and_contributions(store, sleeve, universe=universe,
unlock_events=unlock_events)
unlock_events=unlock_events,
positioning_coin_gross_cap=positioning_coin_gross_cap)
days = [d for d in by_day if as_of_day is None or d <= as_of_day]
if not days:
continue
@@ -145,7 +157,8 @@ def derive_and_persist_bybit_paper_book(repo: PaperRepo, store: Any, *, capital:
enabled: bool = True,
cost_rate: float = _BYBIT_COST_RATE,
as_of_day: int | None = None,
real_spread_by_coin: dict[str, float] | None = None) -> int:
real_spread_by_coin: dict[str, float] | None = None,
positioning_coin_gross_cap: float | None = None) -> int:
"""Derive the Bybit live paper book for its latest day and persist it through the shared
`persist_paper_book` seam into `repo` (bybit is `PaperRepo`'s sole venue). Returns the number of
open positions persisted (0 when disabled / no data).
@@ -167,11 +180,17 @@ def derive_and_persist_bybit_paper_book(repo: PaperRepo, store: Any, *, capital:
SAME taker_fee + 0.5·real_spread(coin) the cockpit Backtest charges), so the forward `/paper` step ties to
the Backtest NET; else the flat Bybit 5.5bp turnover haircut (the pre-real-spread path).
`positioning_coin_gross_cap` (positioning sleeve ONLY) forwards into `combined_symbol_weights_and_returns`
so the LIVE persisted positions honor the same per-coin cap the reconciled return uses — a single
over-short coin can no longer take an outsized real position. Default None preserves the byte-identical
uncapped behaviour.
READ-ONLY against the warehouse `store`; only the paper store is written. `enabled=False` is a no-op."""
if not enabled:
return 0
combined, ret_map, day = combined_symbol_weights_and_returns(
store, universe=universe, unlock_events=unlock_events, as_of_day=as_of_day)
store, universe=universe, unlock_events=unlock_events, as_of_day=as_of_day,
positioning_coin_gross_cap=positioning_coin_gross_cap)
if not combined or day is None:
return 0
run_date = _epoch_day_to_iso(day)

View File

@@ -159,3 +159,26 @@ def test_positioning_weights_reconcile_with_coin_gross_cap() -> None:
f"positioning day {day}: Σ w·r = {implied[day]!r} != capped stored {stored[day]!r}")
assert any(not math.isclose(stored[d], uncapped_stored[d], rel_tol=1e-9, abs_tol=1e-12)
for d in common), "cap had no effect on the fixture — strengthen the seed to exercise it"
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}"