# Bybit Testnet Real-Paper Execution Leg — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans, task-by-task. Steps use `- [ ]` checkboxes.

**Goal:** Run the `bybit_4edge` book as a real-paper track on Bybit testnet — real orders/fills, the risk overlay re-applied on the testnet book's own state, and decomposed, tested slippage — to validate execution *mechanics* before mainnet micro-live.

**Architecture:** A SEPARATE Dagster job (`bybit_testnet_execution_job`, no CronJob) reads the latest raw `bybit_4edge` sleeve weights, re-applies the overlay on testnet equity/drawdown, diffs vs testnet positions, places ccxt market orders on Bybit testnet, and records fills with decomposed slippage + a post-rebalance NAV. Pure logic (slippage, order planning, overlay-reapply, execution-gap verdict) is unit-tested; the adapter is tested against mocked ccxt.

**Tech stack:** Python 3.12, ccxt (bybit sandbox), Dagster, SQLAlchemy/Postgres (bybit_features warehouse = TimescaleFeatureStore), pytest. Spec: `docs/superpowers/specs/2026-06-28-bybit-testnet-real-paper-execution-design.md`.

**Sources of truth to mirror:** `src/fxhnt/adapters/broker/ibkr.py` (broker shape), `src/fxhnt/application/execution.py` (exec protocol), the existing risk-overlay code (single source — do NOT reimplement), `config.py:59` crypto-exec config (triple-gate pattern).

---

## PHASE 1 — execution loop (dry-runnable + testnet), no cockpit

### Task 1: Pin the raw pre-overlay sleeve-weights source (LOAD-BEARING — do first)

**Files:** read `src/fxhnt/adapters/orchestration/assets.py` (the `bybit_4edge` / `bybit_paper_book` assets), `src/fxhnt/application/paper_book.py`, the sleeve strategies' `current_weights(as_of)`.

- [ ] **Step 1:** Trace how `bybit_4edge` builds its book. Determine whether the RAW per-sleeve target weights (pre-overlay) for the latest `as_of` are persisted/exposed anywhere, or only post-overlay sim positions.
- [ ] **Step 2:** If a clean raw-weights read-model exists, document the exact function/table to call. If NOT, add a minimal read-model: a function `latest_raw_sleeve_weights(repo) -> dict[str, float]` that returns the strategies' `current_weights(latest_as_of)` for the bybit_4edge sleeves (the same inputs the book uses pre-overlay). Add a test that it returns the expected weights for a seeded as_of.
- [ ] **Step 3 (GATE):** If the raw weights cannot be obtained cleanly (only sim-marked positions available), STOP and escalate — the leg's correctness depends on this. Do not proceed to Task 4 until resolved.

### Task 2: `slippage.py` — pure slippage model (TDD)

**Files:** Create `src/fxhnt/application/slippage.py`; Test `tests/unit/test_slippage.py`.

- [ ] **Step 1: Failing tests**
```python
import math
from fxhnt.application.slippage import (
    execution_slippage_bps, timing_drift_bps, slippage_vs_assumed_bps)

def test_buy_pays_above_mid_is_positive():
    # bought at 101 vs mid 100 → +100 bps cost
    assert execution_slippage_bps(101.0, 100.0, "BUY") == 100.0
def test_sell_below_mid_is_positive_cost():
    # sold at 99 vs mid 100 → +100 bps cost
    assert execution_slippage_bps(99.0, 100.0, "SELL") == 100.0
def test_timing_drift_sign():
    # BUY, mid moved 100→101 from the book mark → +100 bps adverse drift
    assert timing_drift_bps(101.0, 100.0, "BUY") == 100.0
def test_decomposition_sums_to_total():
    fill, mid, mark = 102.0, 101.0, 100.0
    total = execution_slippage_bps(fill, mid, "BUY") + timing_drift_bps(mid, mark, "BUY")
    # fill-vs-mark for a BUY = (102-100)/100*1e4 = 200
    assert abs(total - 200.0) < 1e-6
def test_assumed_delta():
    assert slippage_vs_assumed_bps(40.0, 35.0) == 5.0  # 5bp worse than the 35bp tier
def test_guards_none_on_bad_inputs():
    assert execution_slippage_bps(101.0, 0.0, "BUY") is None
    assert timing_drift_bps(101.0, -1.0, "SELL") is None
```
- [ ] **Step 2:** Run → FAIL (module missing).
- [ ] **Step 3: Implement**
```python
from __future__ import annotations
def _sign(side: str) -> int:
    return 1 if side.upper() == "BUY" else -1
def execution_slippage_bps(fill: float, mid_at_order: float, side: str) -> float | None:
    if mid_at_order <= 0.0: return None
    return _sign(side) * (fill - mid_at_order) / mid_at_order * 1e4
def timing_drift_bps(mid_at_order: float, book_mark: float, side: str) -> float | None:
    if book_mark <= 0.0: return None
    return _sign(side) * (mid_at_order - book_mark) / book_mark * 1e4
def slippage_vs_assumed_bps(execution_slippage: float, assumed_tier_bps: float) -> float:
    return execution_slippage - assumed_tier_bps
```
- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `feat(exec): pure decomposed slippage model`.

### Task 3: `execution_gap.py` — mechanics verdict + indicative slippage (TDD)

**Files:** Create `src/fxhnt/application/execution_gap.py`; Test `tests/unit/test_execution_gap.py`.

- [ ] **Step 1: Failing tests** — a `mechanics_verdict(rows, target_positions, tolerance) -> Verdict` that is PASS when every recorded post-rebalance position tracks its effective target within `tolerance` and there are no error/gap rows; FAIL otherwise with the offending symbol(s). And `indicative_slippage(fills) -> dict[tier, {mean_delta_bps, coverage}]` aggregating `slippage_vs_assumed` per cost-tier, EXCLUDING `low_confidence` fills, reporting coverage (n real-liquidity fills / n).
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement pure functions over the recorded-row DTOs. **Step 4:** Run → PASS. **Step 5:** Commit `feat(exec): execution-gap mechanics verdict + indicative slippage`.

### Task 4: `bybit_testnet_execution.py` — overlay-reapply + order planning (TDD)

**Files:** Create `src/fxhnt/application/bybit_testnet_execution.py`; Test `tests/unit/test_bybit_testnet_execution.py`. Reuse the EXISTING overlay (import it; do not reimplement).

- [ ] **Step 1: Failing tests for `effective_weights`** — given raw sleeve weights + a testnet nav history that is in deep drawdown, the re-applied overlay killswitch de-risks (weights → 0/reduced); given a calm testnet history, weights pass through vol-targeted. Assert it calls the SAME overlay used by the book (parametrize/inject it).
- [ ] **Step 2: Failing tests for `plan_orders`**:
```python
def test_buy_when_target_above_current():
    intents = plan_orders({"BTCUSDT": 0.5}, {"BTCUSDT": 0.0}, equity=10_000,
                          marks={"BTCUSDT": 100.0}, limits=_lim())
    assert intents == [OrderIntent("BTCUSDT", qty=50.0, reduce_only=False)]  # 0.5*10000/100
def test_reduce_only_on_close():
    intents = plan_orders({"BTCUSDT": 0.0}, {"BTCUSDT": 50.0}, 10_000, {"BTCUSDT":100.0}, _lim())
    assert intents[0].reduce_only is True and intents[0].qty == -50.0
def test_min_notional_filters_dust():
    intents = plan_orders({"X": 0.0001}, {"X": 0.0}, 10_000, {"X": 100.0}, _lim(min_notional=50))
    assert intents == []  # 0.0001*10000=1 USD < 50 minNotional
def test_qty_step_snap_and_gross_cap():
    # Σ|weight·equity| > equity*max_gross(1.0) → clipped
    ...
```
- [ ] **Step 3:** Implement `effective_weights` (overlay re-apply on testnet nav) + `plan_orders` (qtyStep snap, minNotional/minOrderQty filter, reduce_only, gross-cap assert+clip). **Step 4:** PASS. **Step 5:** Commit `feat(exec): bybit testnet overlay-reapply + diff order planning`.

### Task 5: `BybitExecution` ccxt adapter (TDD, mocked ccxt)

**Files:** Create `src/fxhnt/adapters/broker/bybit.py`; Test `tests/unit/test_bybit_execution_adapter.py`.

- [ ] **Step 1: Failing tests** with a fake ccxt exchange: `set_sandbox_mode(True)` called when testnet; `place_market` issues `create_order(symbol,'market',side,abs(qty),params={'reduceOnly':...})`; `mid` returns bid/ask midpoint and `None` on an empty/one-sided book; `instrument_limits` parses minOrderQty/qtyStep/minNotional from `load_markets`; api_secret never appears in `repr`/logs.
- [ ] **Step 2:** FAIL. **Step 3:** Implement (ccxt.bybit; inject the exchange factory for tests). **Step 4:** PASS. **Step 5:** Commit `feat(broker): ccxt Bybit testnet execution adapter`.

### Task 6: Persistence — tables + repo (TDD)

**Files:** Modify `src/fxhnt/adapters/persistence/cockpit_models.py` (or the relevant models) + the repo; migration in the repo `migrate()`; Test `tests/integration/test_bybit_testnet_repo.py`.

- [ ] **Step 1: Failing tests** — `upsert_testnet_nav`, `read_testnet_nav`, `insert_testnet_fills`, `read_testnet_fills`, and a per-day `executed_marker` check (idempotency).
- [ ] **Step 2:** FAIL. **Step 3:** Add tables `bybit_testnet_nav` + `bybit_testnet_fills` (schema per spec §4.4) `CREATE TABLE IF NOT EXISTS` in migrate(); repo methods. **Step 4:** PASS. **Step 5:** Commit `feat(persist): bybit testnet nav + fills tables`.

### Task 7: Config — `BybitExecutionConfig` (TDD)

**Files:** Modify `src/fxhnt/config.py`; Test `tests/unit/test_config.py`.

- [ ] **Step 1: Failing test** — env `FXHNT_BYBIT_EXEC_*` populates the config; defaults `testnet=True, allow_live=False, kill_switch=False, max_gross=1.0`; a `can_trade_live` property is True ONLY when `testnet=False AND allow_live=True AND kill_switch=False`.
- [ ] **Step 2:** FAIL. **Step 3:** Implement. **Step 4:** PASS. **Step 5:** Commit `feat(config): BybitExecutionConfig with triple-gate`.

### Task 8: Dagster job — reconcile + record assets + wiring (TDD)

**Files:** Modify `src/fxhnt/adapters/orchestration/assets.py` + `definitions.py`; Test `tests/integration/test_bybit_testnet_job_wiring.py`.

- [ ] **Step 1: Failing tests** — `bybit_testnet_execution_job` exists; it contains `bybit_testnet_reconcile` + `bybit_testnet_record`; it is NOT in `combined_book_forward_job`; schedule `bybit_testnet_daily` registered; `kill_switch=True` → reconcile places zero orders (asset-level test with a fake adapter); a per-day marker → a second run is a no-op.
- [ ] **Step 2:** FAIL. **Step 3:** Implement the two assets (reconcile: build adapter from settings, raw weights → effective_weights → plan_orders → per-order mid+place+fill, best-effort BrokerError → structured result, idempotency guard; record: write fills+nav). Register the job + schedule (default_status per ops; start STOPPED until creds present). **Step 4:** PASS. **Step 5:** Commit `feat(orch): bybit testnet execution Dagster job + schedule`.

### Task 9: Infra manifests (no CronJob)

**Files:** Create `infra/k8s/secrets/bybit-testnet-credentials.yaml` (template, NO real keys committed), `infra/k8s/network-policies/bybit-testnet-egress.yaml` (dagster pod → api-testnet.bybit.com:443).

- [ ] **Step 1:** Add the secret template (documented: populate via `kubectl create secret`, never commit keys) + the NetworkPolicy. **Step 2:** Commit `chore(infra): bybit testnet secret template + egress netpol`.

### Phase-1 acceptance
A testnet run (creds present, schedule enabled) reconciles the book, places real testnet orders, records fills with decomposed slippage + a NAV row, and `mechanics_verdict` computes — all without touching `combined_book_forward_job` or real money. Full suite green.

---

## PHASE 2 — surfacing (outline, separate plan when P1 lands)
- Cockpit "real-paper" card reading `bybit_testnet_*`: equity curve, mechanics verdict (PASS/FAIL + offending symbols), indicative slippage per tier vs assumed (with coverage + the "magnitude pending mainnet" label), side-by-side vs the sim `paper_nav`.
- Wire into the dashboard service + a template; verify locally per the cockpit dev-loop (real browser, read the numbers).

---

## Self-review notes
- Spec coverage: every spec §4 component → a task; slippage (§3) → Task 2; verdict (§5) → Task 3; load-bearing dep (§6) → Task 1 gate.
- No placeholders in pure-logic tasks (code given). Integration tasks specify files + the exact assertions.
- Type consistency: `OrderIntent(symbol, qty, reduce_only)`, `Fill(symbol, side, qty, avg_price, fee, ts, order_id)`, slippage fns return `float|None` — used consistently across Tasks 2/4/5/8.
- Deferred honestly: slippage MAGNITUDE validation (mainnet micro-live), cockpit (Phase 2), real money (separate spec).
