# Bybit Testnet "Real-Paper" Execution Leg — Design (v2)

**Date:** 2026-06-28
**Status:** Design (pending review) — v2 after critical self-review
**Goal (REVISED):** Validate the **execution mechanics** of the `bybit_4edge` book on **Bybit testnet** — order placement, real fills, position reconciliation, and the risk-overlay applied to the *real* book state — with **measured, decomposed slippage** as first-class, tested output. Fake money.

> **Honest scope correction (from review #2):** Bybit testnet has thin liquidity in the small alts the unlock-shorts trade, so testnet **cannot hard-validate the slippage *magnitudes*** (esp. the 35bp unlock assumption). Testnet validates that the *loop is correct* (orders/fills/reconcile/overlay-on-real-state) and yields an **indicative / upper-bound** slippage. The slippage-magnitude truth — the 5/8/6/35bp tiers — is only confirmed by **mainnet micro-live** (a later, separate, real-money step gated on the recon gate). This leg de-risks the *mechanics* of that step and instruments the slippage measurement so micro-live inherits a tested calculation.

---

## 1. Key decisions (locked)

| Decision | Choice | Why |
|---|---|---|
| Execution adapter | **ccxt** (`ccxt.bybit` + `set_sandbox_mode(True)`) | Reuses the ccxt crypto-exec config; exchange-agnostic. |
| Order type | **Market** | Honest slippage; deterministic reconcile. |
| Orchestration | **Separate Dagster job + schedule** (no CronJob) | Decoupled → a broker hiccup never taints the forward gate's clean-execution record. |
| Money | **Testnet only** (triple-gate) | Mechanics now; real money is post-gate micro-live. |
| **Weights to execute** | **RAW pre-overlay sleeve weights**, overlay **re-applied on testnet state** (review #1) | The overlay (vol-target/gross-cap/killswitch) must act on the testnet book's OWN equity/drawdown, not the sim's. |
| **Slippage baseline** | **Live orderbook mid at order time** (review #3) | Isolates execution slippage from price drift; the mark-close drift is a separate, measured term. |

---

## 2. Architecture

```
combined_book_forward_job (23:30 UTC, EXISTING) — UNCHANGED
  └─ persists RAW per-sleeve target weights (pre-overlay)  [see §6 — load-bearing dependency]

bybit_testnet_execution_job (NEW, SEPARATE, schedule bybit_testnet_daily)
  ├─ bybit_testnet_reconcile
  │   1. read RAW sleeve weights (latest) + testnet equity + testnet positions
  │   2. re-apply the SAME risk overlay on the TESTNET equity/drawdown → effective target weights
  │   3. plan_orders(effective_weights, testnet_positions, equity, instrument_limits)
  │   4. for each order: capture live mid → place MARKET order → fill
  └─ bybit_testnet_record
      write fills (execution + timing slippage decomposed) + post-rebalance testnet NAV
      → bybit_testnet_fills / bybit_testnet_nav
```

Decoupled by schedule timing only (not a Dagster dep): a broker failure fails this job alone.

---

## 3. Slippage — the measurement model (pure, fully tested)  ⭐ (answers "can we test slippage?")

All in `src/fxhnt/application/slippage.py` — **pure functions over floats**, no I/O, so they're unit-tested directly. The adapter supplies `fill_price`, `mid_at_order`, `book_mark`; the math is tested in isolation.

```
sign(side)                         = +1 for BUY, -1 for SELL
execution_slippage_bps(fill, mid_at_order, side)
    = sign(side) * (fill - mid_at_order) / mid_at_order * 1e4     # >0 = paid worse than mid (the cost we model as 5/8/6/35bp)
timing_drift_bps(mid_at_order, book_mark, side)
    = sign(side) * (mid_at_order - book_mark) / book_mark * 1e4    # market moved between the book's mark and execution
total_fill_vs_mark_bps = execution_slippage_bps + timing_drift_bps
slippage_vs_assumed_bps(execution_slippage_bps, assumed_tier_bps)
    = execution_slippage_bps - assumed_tier_bps                    # the validation delta vs the cost-model tier
```

- `assumed_tier_bps` per symbol from the A8 cost tiers (taker 5bp + tier 8/6/35bp; unlock symbols → 35bp).
- **Decomposition is the whole point:** `execution_slippage` is what the cost model claims to capture; `timing_drift` is the close→execution price move (a separate, instrument-able effect). Conflating them (v1's bug) made the number meaningless.
- **Guards (tested):** `mid<=0` or `book_mark<=0` → return `None` (undefined), never divide-by-zero or emit a garbage bp.
- **Tests:** sign both sides; a known fill/mid → exact bps; decomposition sums to total; the assumed-delta; the None-guards; a thin-book sentinel (if `mid` from an empty/one-sided book → flagged `low_confidence`, recorded but excluded from the aggregate verdict — review #2).

---

## 4. Components

### 4.1 `BybitExecution` adapter — `src/fxhnt/adapters/broker/bybit.py`
ccxt-backed; shape mirrors `adapters/broker/ibkr.py`.
- `__init__(api_key, api_secret, *, testnet=True)` → `ccxt.bybit(...)`; `set_sandbox_mode(testnet)`.
- `equity() -> float`; `positions() -> dict[str,float]` (signed qty); `instrument_limits() -> dict[str,InstrumentLimit]` (`minOrderQty`, `qtyStep`, `minNotional` from `load_markets`) — review #6.
- `mid(symbol) -> float | None` — `fetch_order_book` best bid/ask → mid; `None` if empty/one-sided (thin testnet book).
- `place_market(symbol, signed_qty, *, reduce_only) -> Fill` — `create_order('market', side, abs(qty), params={'reduceOnly': reduce_only})`.
- Errors → typed `BrokerError`; **never logs secrets**.

### 4.2 Reconcile + overlay re-application — `src/fxhnt/application/bybit_testnet_execution.py` (pure where possible)
- `effective_weights(raw_sleeve_weights, testnet_nav_history) -> dict[str,float]` — **re-runs the existing risk overlay** (vol-target, gross-cap ≤1.0×, drawdown killswitch) against the **testnet** equity/drawdown (review #1). Reuses the SAME overlay code the book uses (single source of truth — no reimplementation), fed testnet state. If the testnet drawdown trips the killswitch, the testnet book de-risks **on its own state** — that's the behavior we're validating.
- `plan_orders(effective_weights, current_positions, equity, marks, limits) -> list[OrderIntent]`:
  - target_qty[s] = (weight·equity)/marks[s], snapped to `qtyStep`; drop if `|target_qty·mark| < max(minNotional, min_order_usd)` or `< minOrderQty` (review #6).
  - delta = target_qty − current; `reduce_only=True` on reductions/closes.
  - assert Σ|weight·equity| ≤ equity·`max_gross`(=1.0) → clip + log (no leverage).
- Pure → the bulk of TDD.

### 4.3 Dagster job — `assets.py` + `definitions.py`
- `@asset bybit_testnet_reconcile` (builds adapter from settings, runs §2 steps 1–4; per-order: `mid` → place → fill; best-effort `BrokerError` → log + structured result, never crash the daemon; **idempotency guard:** a per-day `(date)` marker in `bybit_testnet_nav` + an open-orders check before placing → re-run is a no-op, review #idempotency).
- `@asset bybit_testnet_record` (writes fills with decomposed slippage + post-rebalance NAV).
- Job `bybit_testnet_execution_job`; schedule `bybit_testnet_daily` (configurable; default 00:30 UTC). **Not** in `combined_book_forward_job`.

### 4.4 Persistence — migration
- `bybit_testnet_nav(run_date PK, equity, realized_pnl, gross, n_orders, mean_exec_slippage_bps, mean_timing_drift_bps, ts)`.
- `bybit_testnet_fills(id PK, run_date, symbol, side, qty, fill_price, mid_at_order, book_mark, exec_slippage_bps, timing_drift_bps, assumed_tier_bps, slippage_delta_bps, low_confidence bool, fee, order_id, ts)`.
- Separate from sim `paper_nav` → cockpit shows real-paper vs sim; recon gate keeps reading the sim track unchanged.

### 4.5 Config — `config.py`
`BybitExecutionConfig` (`FXHNT_BYBIT_EXEC_`): `api_key`, `api_secret`, `testnet=True`, `allow_live=False`, `kill_switch=False`, `min_order_usd=10`, `max_gross=1.0`, `schedule_cron`. **Triple-gate** for real money: `testnet=False` AND `allow_live=True` AND `kill_switch=False`.

### 4.6 Infra
k8s secret `bybit-testnet-credentials`; NetworkPolicy egress `api-testnet.bybit.com:443` for the dagster pod. **No CronJob** — Dagster schedule.

---

## 5. Execution-gap verdict (review #5) — `src/fxhnt/application/execution_gap.py` (pure)
Defines the analog of the recon gate, for execution:
- **MECHANICS PASS:** over the window, post-rebalance testnet positions track the effective target within `pos_tolerance` (e.g. each symbol within 1 qtyStep + dust), no reconcile errors, no execution gaps (clean daily record).
- **SLIPPAGE REPORT (indicative):** aggregate `exec_slippage_bps` per cost-tier vs `assumed_tier_bps`, **excluding `low_confidence` (thin-book) fills**; report the delta + the coverage (how many symbols had real liquidity). Explicitly labeled "indicative — magnitude pending mainnet micro-live."
- Pure fn over the recorded rows → unit-tested (pass/fail on synthetic mechanics; slippage aggregation with low-confidence exclusion).

---

## 6. ⚠️ Load-bearing dependency (review #7) — verify FIRST in the plan
The reconcile needs the **raw, pre-overlay per-sleeve target weights** — NOT sim-marked positions. **Task 1 of the plan = locate/expose this.** If the forward job only persists post-overlay sim positions, add a small read-model that exposes the raw sleeve weights (the strategies already compute `current_weights(as_of)`; expose that for the latest as_of). If this turns out unavailable cleanly, STOP and re-evaluate — the whole leg depends on a clean weight source.

---

## 7. Phasing (review — scope)
- **Phase 1 — execution loop (dry-runnable):** slippage.py + execution_gap.py (pure + fully tested) → BybitExecution adapter (mocked-ccxt tests) → reconcile/overlay-re-apply → Dagster job + tables + config + secret + netpol. Acceptance: a testnet run reconciles the book, records fills with decomposed slippage, mechanics-verdict computes. Cockpit not required.
- **Phase 2 — surfacing:** cockpit real-paper card (equity curve, mechanics verdict, indicative slippage vs assumed) reading `bybit_testnet_*`.

---

## 8. Safety
Testnet-only default + triple-gate; `kill_switch` → zero orders (log intent only); `max_gross=1.0` hard assert (no leverage); idempotent diff + per-day marker + open-orders check; decoupled job.

## 9. Error handling
`BrokerError` → asset logs + records `{"error":...}`, **no NAV row** (a visible gap, not a silent pass); partial fills self-correct next day; testnet demo-fund reset → discontinuity marker + NAV re-baseline.

## 10. Testing (TDD)
- **slippage.py** (§3): sign/bps/decomposition/assumed-delta/None-guards/low-confidence — pure, exhaustive.
- **execution_gap.py** (§5): mechanics pass/fail; slippage aggregation excluding low-confidence.
- **plan_orders / effective_weights**: target>current→buy; <→reduce_only; qtyStep snap; minNotional/minOrderQty filter; gross>1.0 clip; overlay killswitch trips on testnet drawdown.
- **BybitExecution**: mocked ccxt — sandbox set, market params (reduceOnly), fill/mid/limits parsing, secret never logged.
- **Job wiring**: job exists, on `bybit_testnet_daily`, NOT in `combined_book_forward_job`, idempotent re-run = no-op, kill_switch = zero orders.
- Full suite green.

## 11. Out of scope
Real-money micro-live (separate, post-gate); limit/post-only; multi-venue. **Slippage-magnitude validation for the slippage-sensitive edges is explicitly deferred to mainnet micro-live** — testnet gives mechanics + indicative slippage only.

## 12. Open risks (honest)
- Testnet alt liquidity → many `low_confidence` fills; the unlock-35bp magnitude likely NOT validated here (the core caveat — drives the goal restatement).
- Testnet funding ≠ mainnet → funding economics indicative only.
- Close→execution timing drift is measured (`timing_drift_bps`) but not eliminated; for cleaner magnitude, mainnet micro-live should execute nearer the book's mark time.
