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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user