diff --git a/src/fxhnt/adapters/orchestration/assets.py b/src/fxhnt/adapters/orchestration/assets.py index a11fc7c..730ca16 100644 --- a/src/fxhnt/adapters/orchestration/assets.py +++ b/src/fxhnt/adapters/orchestration/assets.py @@ -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 diff --git a/src/fxhnt/application/backtest_refs_report.py b/src/fxhnt/application/backtest_refs_report.py index 8b47088..9fe35f2 100644 --- a/src/fxhnt/application/backtest_refs_report.py +++ b/src/fxhnt/application/backtest_refs_report.py @@ -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`. diff --git a/src/fxhnt/application/bybit_forward_track.py b/src/fxhnt/application/bybit_forward_track.py index 57b4703..89ab113 100644 --- a/src/fxhnt/application/bybit_forward_track.py +++ b/src/fxhnt/application/bybit_forward_track.py @@ -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) diff --git a/src/fxhnt/application/bybit_paper_book.py b/src/fxhnt/application/bybit_paper_book.py index 737b289..3bc4b40 100644 --- a/src/fxhnt/application/bybit_paper_book.py +++ b/src/fxhnt/application/bybit_paper_book.py @@ -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) diff --git a/tests/integration/test_bybit_paper_reconciliation.py b/tests/integration/test_bybit_paper_reconciliation.py index c1026f0..e9ad9a5 100644 --- a/tests/integration/test_bybit_paper_reconciliation.py +++ b/tests/integration/test_bybit_paper_reconciliation.py @@ -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}"