# C3 — Execution Unification: bybit→CLI port + paper-validation envelope + real exec-venue recording

**Date:** 2026-07-10 (rev 2026-07-11 — unified scope)
**Status:** Approved (design)
**Depends on:** A (deterministic forward state), B2a (allocation), B3 (funding), C1 (Alpaca exec leg), C2 (Bybit exec leg) — all merged.

## Problem

Three things converge:

1. **Two execution legs, two architectures.** Multistrat execution is a CLI command
   (`execute-multistrat`) run by a k8s CronJob; the Bybit leg is a pair of **Dagster assets**
   (`bybit_testnet_reconcile` + `bybit_testnet_record`) run by the `bybit_testnet_job` schedule.
   Dagster should orchestrate *pipelining* (research, forward-recording); *execution* legs — which
   need a scheduled pod-to-pod / external broker path independent of the research nightly — belong
   on the CLI + CronJob pattern. The Bybit leg contradicts that boundary.

2. **The exec track cannot run continuously.** Both executed paper tracks (`multistrat_exec`,
   `bybit_4edge_exec`) are funded by B2a's real-capital allocation
   (`envelope = min(current_allocation[sid], nlv)`). B2a correctly sets `multistrat = $0` (edge
   <60 forward days), so the next `--execute` books one return day then **flattens**. Paper-execution
   *validation* is a different concern from real-capital *allocation*, yet they share one envelope.

3. **The executed-reality badge lies about the venue.** The cockpit renders `d.venue` — the sleeve's
   *configured* venue (`alpaca-paper`) — but the first run executed on **IBKR paper**. Since IBKR
   whole-share and Alpaca fractional have different friction (the whole point of the divergence
   readout), the badge must reflect where fills actually happened.

## Goals

- **Port the Bybit execution leg off Dagster into a CLI command** (`execute-bybit`), run by a new
  `fxhnt-bybit-rebalancer` CronJob — mirroring `execute-multistrat`. Fully remove the two Dagster
  exec assets, the `bybit_testnet_job`, its schedule, and the `_testnet_*` asset seams (clean cut,
  no dead code).
- A **separate paper-validation funding envelope**, uniform across both legs via the identical
  `--paper-envelope` CLI flag, NOT gated by B2a — so paper books run continuously while B2a still
  governs real capital unchanged.
- Record the **actual execution venue** on each exec track and surface it in the cockpit badge.
- Arm both CronJobs to run the continuous paper experiment.

## Non-Goals / Hard Constraints

- **Real money errs false-WAIT.** The paper envelope MUST hard-refuse (place zero orders) on any
  non-paper/non-testnet account. It never reads or writes `strategy_allocation` → real-capital
  policy untouched by construction.
- `--paper-envelope 0` (default) ⇒ behavior identical to today (B2a-gated).
- No schema migration — `venue` rides in existing JSONB book-state.
- **Faithful port, no behavior change** to the Bybit exec logic itself: the CLI path must preserve
  every safety guard the assets have (kill-switch, per-day NAV idempotency marker, open-orders
  resting guard, per-order `BrokerError` → structured gap row, low-confidence slippage exclusion,
  funding-cursor advance). This is a relocation, not a rewrite.
- Out of scope: the deferred crypto re-entry-cost accounting noted in `bybit_exec_record.py`.

## Design

### Component 1 — Port the Bybit execution leg to a CLI command

**New application function** `src/fxhnt/application/bybit_testnet_run.py`:

```python
def run_bybit_testnet(settings, *, paper_envelope: float, execute: bool,
                      log: Callable[[str], None]) -> dict:
    """Reconcile + record the bybit_4edge book on Bybit TESTNET in one process (the Dagster
    reconcile→record split collapses into a single CLI run, like plan_and_record for multistrat).
    Preserves every guard: kill-switch, NAV-marker idempotency, open-orders resting guard, per-order
    BrokerError→gap row, funding-cursor advance. Returns a summary dict (placed, equity, envelope,
    slippage means, exec_return, venue)."""
```

Its body is the current `bybit_testnet_reconcile` + `bybit_testnet_record` logic concatenated,
with three mechanical changes: `context.log.info/warning/error` → `log(...)`; the reconcile→record
dict handoff becomes local variables; envelope is paper-aware (Component 2). The five
`_testnet_*` factory seams (`_testnet_store/_universe/_unlock_events/_marks/_adapter`) **move** from
`assets.py` into this module (still monkeypatchable seams for tests). When `execute` is False it runs
the plan + slippage estimate but places no orders and writes no NAV (dry-run), mirroring
`execute-multistrat`'s dry-run.

**New CLI command** `execute-bybit` in `cli.py`:

```python
@app.command("execute-bybit")
def execute_bybit(
    do_execute: bool = typer.Option(False, "--execute", help="place orders (default: dry-run)"),
    paper_envelope: float = typer.Option(0.0, "--paper-envelope",
        help="PAPER-only validation envelope in $ (bypasses B2a; refused on live). 0 = B2a-gated."),
) -> None:
    ...  # get_settings(); run_bybit_testnet(...); echo the summary
```

**Cleanup (clean cut):**
- `assets.py`: delete `bybit_testnet_reconcile`, `bybit_testnet_record`, and the five `_testnet_*`
  helpers (moved to `bybit_testnet_run.py`).
- `definitions.py`: remove the `bybit_testnet_reconcile`/`bybit_testnet_record` imports,
  `bybit_testnet_job` (`define_asset_job`), and `bybit_testnet_schedule` (`ScheduleDefinition`).
  Confirm `Definitions(...)` still loads with the remaining pipelining assets.
- Tests referencing the assets (`tests/integration/test_record_bybit_exec_track.py` and any Dagster
  materialization tests of these assets) are re-pointed at `run_bybit_testnet` / `execute-bybit`.

### Component 2 — Paper-validation envelope (uniform CLI flag)

**Config (shared default):** `PaperValidationSettings` in `config.py` (env prefix
`FXHNT_PAPER_VALIDATION_`), one field `envelope: float = 0.0`; exposed as `settings.paper_validation`.

**Both legs** take the identical `--paper-envelope <float>` CLI flag (default `0.0`). Resolution in
each command: `paper_env = flag if flag > 0 else settings.paper_validation.envelope`.

**Envelope selection** — `plan_and_record` (multistrat) and `run_bybit_testnet` (bybit), via the
shared-shape guard:

```
if paper_envelope > 0:                       # paper-validation mode — bypasses B2a entirely
    if not account_is_paper:                 # SAFETY GATE — refuse, place NO orders
        raise ValueError("paper-envelope refused: account is not paper/testnet")
    envelope = min(paper_envelope, nlv)      # nlv = alpaca NLV / bybit testnet equity
else:                                         # real-capital mode — UNCHANGED
    envelope = min(current_allocation[sid], nlv)
```

`account_is_paper` comes from `AccountState.is_paper` (multistrat: `broker.account_state().is_paper`,
already `"paper-api" in base_url` / `DU`-prefix) and `settings.bybit_exec.testnet` (bybit). In paper
mode `current_allocation` is never read.

For bybit the helper lives in `bybit_exec_record.py`:
`paper_or_b2a_crypto_envelope(repo, equity, paper_envelope, is_paper) -> float`.

### Component 3 — Real exec-venue recording

Derive `venue = f"{broker.name}-{'paper' if is_paper else 'live'}"` (→ `alpaca-paper`,
`ibkr-paper`); bybit records the literal `"bybit-testnet"`. Persist inside the exec-position
book-state (JSONB, no migration): `build_pos_state(..., venue="")` and
`build_crypto_pos_state(..., venue="")` add `"venue"`. `record_exec_track` / `record_bybit_exec_track`
gain a `venue: str = ""` param, written on every run (incl. inception).

### Component 4 — Cockpit surfacing

- `DashboardService.detail()` reads `self._repo.get_book_state(f"{twin}_pos").get("venue")` via a
  safe helper → new `StrategyDetail.exec_venue: str | None`.
- `strategy.html` exec-tag renders `{{ d.exec_venue or d.venue }}` (recorded venue wins; configured
  venue is the fallback for pre-C3 anchors).

### Component 5 — Wiring & deploy

- `infra/k8s/jobs/fxhnt-alpaca-rebalancer.yaml`: append `--paper-envelope 1000000`; `suspend: false`.
- **New** `infra/k8s/jobs/fxhnt-bybit-rebalancer.yaml`: copy the alpaca-rebalancer template
  (git-sync init + `fxhnt-cockpit` image + `FXHNT_OPERATIONAL_DSN`/`PGPASSWORD`), running
  `fxhnt execute-bybit --execute --paper-envelope 1000000`; env `FXHNT_BYBIT_EXEC_*` from the bybit
  secret; NetworkPolicy allows egress to the Bybit API (external 443) + Postgres 5432 + gitea. Ship
  armed only after the bybit exec secret is confirmed (else `suspend: true` until keys present).
- Revert the `$1M` `strategy_allocation[multistrat]` override → cockpit shows the true B2a `$0`.
- Cancel the queued IBKR paper orders on DU9600528 (one-off `ib_async` job).

## Data Flow (multistrat; bybit is the mirror)

```
CronJob(--paper-envelope 1e6) → execute-multistrat
  → AlpacaBroker.account_state() → nlv, is_paper=True
  → plan_and_record(paper_envelope=1e6, account_is_paper=True, venue="alpaca-paper")
    → is_paper ✓ → envelope = min(1e6, nlv)               [B2a NOT read]
    → rebalance_weights(...) → compute_exec_return(prior)
    → record_exec_track(..., venue="alpaca-paper")
      → run_track("multistrat_exec", return)              [A's forward store]
      → set_book_state("multistrat_exec_pos", {positions, prices, envelope, venue})
Cockpit → detail("multistrat") → exec_venue="alpaca-paper" → badge renders it
```

## Error Handling

- Paper envelope on a live/non-paper account → hard refuse, no orders, non-zero exit.
- `paper_envelope=0` → today's B2a behavior (regression-tested).
- Missing `venue` in an older book-state → `.get("venue")` → `None` → badge falls back to `d.venue`.
- Bybit CLI preserves the assets' guards: kill-switch (zero orders), NAV-marker no-op, open-orders
  resting → abort with structured error, per-order `BrokerError` → gap row (never a crash).

## Testing

Unit:
- `settings.paper_validation.envelope` default/env.
- `plan_and_record`: paper>0+paper→min(paper,nlv), B2a not read; paper>0+live→refuse, no orders;
  paper=0→B2a. Same three for `paper_or_b2a_crypto_envelope`.
- venue string derivation; `build_pos_state`/`build_crypto_pos_state` persist `venue`.
- `run_bybit_testnet`: kill-switch → zero orders; NAV-marker → no-op; dry-run (`execute=False`) →
  no orders/no NAV; paper-envelope refuse on non-testnet. (Uses the monkeypatchable `_testnet_*`
  seams to avoid Timescale/ccxt.)
- `_exec_pair`/`detail`: `exec_venue` from book-state; missing → `None`.

Local cockpit verify (per `feedback_verify_cockpit_locally_not_prod`): seed exec book-state venues,
`scripts/dev-cockpit-local.sh`, Playwright-screenshot `/strategy/multistrat` + `/strategy/bybit_4edge`,
**read** the badge shows the real venue.

## File Touchpoints

| File | Change |
|---|---|
| `src/fxhnt/config.py` | `PaperValidationSettings` + `settings.paper_validation` |
| `src/fxhnt/application/bybit_testnet_run.py` | **new** — `run_bybit_testnet` + moved `_testnet_*` seams (ported asset logic) |
| `src/fxhnt/application/multistrat_exec_record.py` | paper-envelope branch + safety gate; `venue` in `build_pos_state`/`record_exec_track` |
| `src/fxhnt/application/bybit_exec_record.py` | `paper_or_b2a_crypto_envelope`; `venue` in `build_crypto_pos_state`/`record_bybit_exec_track` |
| `src/fxhnt/adapters/orchestration/assets.py` | **delete** `bybit_testnet_reconcile`, `bybit_testnet_record`, `_testnet_*` helpers |
| `src/fxhnt/adapters/orchestration/definitions.py` | **remove** bybit exec imports, `bybit_testnet_job`, `bybit_testnet_schedule` |
| `src/fxhnt/cli.py` | `execute-multistrat --paper-envelope` + venue; **new** `execute-bybit` command |
| `src/fxhnt/application/dashboard_service.py` | `detail` reads book-state `venue` → `exec_venue` |
| `src/fxhnt/ports/dashboard.py` | `StrategyDetail.exec_venue` |
| `src/fxhnt/adapters/web/templates/strategy.html` | exec-tag → `{{ d.exec_venue or d.venue }}` |
| `infra/k8s/jobs/fxhnt-alpaca-rebalancer.yaml` | `--paper-envelope 1000000`, `suspend: false` |
| `infra/k8s/jobs/fxhnt-bybit-rebalancer.yaml` | **new** CronJob for `execute-bybit` |

## Deploy Sequence (post-merge)

1. Push master → `argo-deploy-cockpit.sh`. No schema migration.
2. Revert `strategy_allocation[multistrat]` → `$0` (or let the nightly do it).
3. Cancel queued IBKR paper orders on DU9600528.
4. Arm `fxhnt-alpaca-rebalancer` (`suspend: false`); confirm bybit exec secret, then arm
   `fxhnt-bybit-rebalancer`.
5. Verify cockpit LOCALLY, then confirm on `dashboard.fxhnt.ai`.
