# Crypto Execution Layer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Build a production-grade, testnet-first execution layer that turns the crypto cross-sectional momentum book's target weights into real Binance USDⓈ-M perp fills — with a rate-limit-safe ccxt adapter, a pure liquidity-aware smart router, full risk controls, and reconciliation + TCA.

**Architecture:** Hexagonal (fxhnt). A `crypto_exchange` port + `binance_ccxt` adapter own all exchange I/O (rate-limit governor, idempotent clientOrderId, filter rounding, testnet via ccxt sandbox). Pure domain modules (`rounding`, `smart_router`, `risk_gates`, `capacity`, `tca`, `reconciliation`) hold all logic and are unit-tested offline. An application orchestrator (`crypto_execution`) wires book→targets→gates→router→exchange→reconcile→persist.

**Tech Stack:** Python 3.11, ccxt (binanceusdm), numpy, pydantic-settings, typer, pytest. Repo `/home/jgrusewski/Work/fxhnt`, branch `feat/crypto-execution` (create from master). Commit author `git -c user.email=jeroen@grusewski.nl -c user.name=jgrusewski`; END every commit body with `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Do NOT push unless asked. Always `cd /home/jgrusewski/Work/fxhnt` (cwd may reset).

---

## File Structure

**Create:**
- `src/fxhnt/ports/crypto_exchange.py` — DTOs (`SymbolFilter`,`OrderBook`,`PerpPosition`,`CryptoAccount`,`ChildOrder`,`Fill`) + `CryptoExchange` Protocol.
- `src/fxhnt/domain/execution/__init__.py`
- `src/fxhnt/domain/execution/rounding.py` — pure filter rounding.
- `src/fxhnt/domain/execution/smart_router.py` — pure order planning (sizing/cap/limit+abort price).
- `src/fxhnt/domain/execution/capacity.py` — pure ADV caps.
- `src/fxhnt/domain/execution/risk_gates.py` — pure pre-trade gates + edge-decay×Kelly scaler.
- `src/fxhnt/domain/execution/tca.py` — pure implementation-shortfall.
- `src/fxhnt/domain/execution/reconciliation.py` — pure drift detection.
- `src/fxhnt/adapters/exchange/__init__.py`, `binance_ccxt.py` — ccxt adapter + rate-limit governor.
- `src/fxhnt/application/crypto_execution.py` — orchestrator.
- `src/fxhnt/adapters/persistence/exec_store.py` — cockpit-DB fill/TCA persistence.
- tests under `tests/unit/` and `tests/integration/`.

**Modify:**
- `pyproject.toml` — add `crypto` optional-dependency group.
- `src/fxhnt/config.py` — add `BinanceSettings`.
- `src/fxhnt/application/crypto_momentum_research.py` — extract `latest_target_weights`.
- `src/fxhnt/adapters/persistence/cockpit_models.py` — add `ExecFillRow`.
- `src/fxhnt/cli.py` — `crypto-rebalance` / `crypto-flatten` / `crypto-positions`.

---

# Milestone 1 — Exchange port + ccxt adapter

### Task 1: Dependencies + BinanceSettings

**Files:** Modify `pyproject.toml`, `src/fxhnt/config.py`

- [ ] **Step 1: Add the crypto dependency group**

In `pyproject.toml` under `[project.optional-dependencies]` add:

```toml
crypto = ["ccxt>=4.4"]
```

- [ ] **Step 2: Install + verify**

Run: `cd /home/jgrusewski/Work/fxhnt && pip install -e '.[dev,crypto]' && python -c "import ccxt; print(ccxt.__version__)"`
Expected: prints a version ≥ 4.4.

- [ ] **Step 3: Write the failing test**

Create `tests/unit/test_binance_settings.py`:

```python
"""BinanceSettings: env-driven, safe defaults (testnet on, live off)."""
from __future__ import annotations

from fxhnt.config import BinanceSettings


def test_safe_defaults() -> None:
    s = BinanceSettings()
    assert s.testnet is True
    assert s.allow_live is False
    assert s.kill_switch is False
    assert 0.0 < s.kelly_cap <= 1.0
    assert s.gross_cap > 0.0 and s.leverage >= 1
```

- [ ] **Step 4: Run it (fails)**

Run: `pytest tests/unit/test_binance_settings.py -v`
Expected: FAIL `ImportError: cannot import name 'BinanceSettings'`

- [ ] **Step 5: Add BinanceSettings**

In `src/fxhnt/config.py`, after `DatabentoSettings`, add:

```python
class BinanceSettings(BaseSettings):
    """Binance USDⓈ-M perp execution. SAFE BY DEFAULT: testnet on, live off, kill-switch off.
    Live trading needs testnet=false AND allow_live=true AND kill_switch=false (three independent gates)."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_BINANCE_")
    api_key: str = ""                 # from env / k8s secret; never logged
    api_secret: str = ""
    testnet: bool = True              # ccxt set_sandbox_mode
    allow_live: bool = False          # must be explicitly true to trade real money
    kill_switch: bool = False         # halt: no new opens
    leverage: int = 3                 # conservative; per-symbol set_leverage
    gross_cap: float = 1.0            # max gross exposure as a multiple of NLV
    per_symbol_cap: float = 0.05      # max single-symbol notional as a fraction of NLV
    participation_rate: float = 0.05  # max order notional as a fraction of symbol ADV (USD)
    depth_cap: float = 0.25           # max order notional as a fraction of top-of-book depth (USD)
    max_slippage_bps: float = 25.0    # abort a symbol if fill drifts more than this vs arrival mid
    hysteresis: float = 0.003         # skip a rebalance delta smaller than this × NLV
    kelly_cap: float = 0.25           # cap on the Kelly fraction
    maker_wait_s: float = 5.0         # how long to wait for the post-only maker fill before crossing
```

Then register it on `Settings` (in the `Settings` class body, beside `databento`):

```python
    binance: "BinanceSettings" = Field(default_factory=BinanceSettings)
```

- [ ] **Step 6: Run it (passes) + commit**

Run: `pytest tests/unit/test_binance_settings.py -v` → PASS

```bash
git add pyproject.toml src/fxhnt/config.py tests/unit/test_binance_settings.py
git commit -m "feat(exec): crypto dep + BinanceSettings (safe defaults: testnet on, live off)"
```

---

### Task 2: crypto_exchange port (DTOs + Protocol)

**Files:** Create `src/fxhnt/ports/crypto_exchange.py`; Test `tests/unit/test_crypto_exchange_dtos.py`

- [ ] **Step 1: Write the failing test**

Create `tests/unit/test_crypto_exchange_dtos.py`:

```python
"""Crypto exchange port DTOs are immutable value objects with the fields the layer depends on."""
from __future__ import annotations

from fxhnt.ports.crypto_exchange import ChildOrder, Fill, OrderBook, PerpPosition, SymbolFilter


def test_dtos_construct() -> None:
    f = SymbolFilter(symbol="BTCUSDT", step_size=0.001, tick_size=0.1, min_notional=5.0)
    ob = OrderBook(symbol="BTCUSDT", bid=100.0, ask=100.2, bid_qty=3.0, ask_qty=4.0)
    p = PerpPosition(symbol="BTCUSDT", qty=-0.5, entry_price=101.0, leverage=3)
    o = ChildOrder(symbol="BTCUSDT", side="BUY", qty=0.01, order_type="LIMIT", price=100.0,
                   reduce_only=False, post_only=True, client_id="fxhnt-1-BTCUSDT-maker", time_in_force="GTX")
    fi = Fill(client_id=o.client_id, symbol="BTCUSDT", side="BUY", qty=0.01, price=100.05, fee=0.001,
              status="FILLED")
    assert ob.mid == 100.1 and p.qty == -0.5 and o.post_only and fi.status == "FILLED"
```

- [ ] **Step 2: Run it (fails)**

Run: `pytest tests/unit/test_crypto_exchange_dtos.py -v`
Expected: FAIL `ModuleNotFoundError: ... crypto_exchange`

- [ ] **Step 3: Implement the port**

Create `src/fxhnt/ports/crypto_exchange.py`:

```python
"""Crypto perp exchange contract. The domain/application depend only on these DTOs + Protocol, never on ccxt.
Perps need more than the equities `Broker` port: exchange filters, order book, leverage, signed positions,
reduce-only/post-only — so this is its own boundary."""
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True, slots=True)
class SymbolFilter:
    symbol: str
    step_size: float     # LOT_SIZE: quantity increment
    tick_size: float     # PRICE_FILTER: price increment
    min_notional: float  # MIN_NOTIONAL (USD)


@dataclass(frozen=True, slots=True)
class OrderBook:
    symbol: str
    bid: float
    ask: float
    bid_qty: float
    ask_qty: float

    @property
    def mid(self) -> float:
        return (self.bid + self.ask) / 2.0


@dataclass(frozen=True, slots=True)
class PerpPosition:
    symbol: str
    qty: float           # signed base quantity (negative = short)
    entry_price: float
    leverage: int


@dataclass(frozen=True, slots=True)
class CryptoAccount:
    nlv: float                       # net liquidation value (USD)
    available_balance: float
    positions: dict[str, float]      # symbol -> signed base qty


@dataclass(frozen=True, slots=True)
class ChildOrder:
    symbol: str
    side: str            # "BUY" | "SELL"
    qty: float           # > 0, already rounded to step_size
    order_type: str      # "LIMIT" | "MARKET"
    price: float | None  # None for MARKET; rounded to tick_size for LIMIT
    reduce_only: bool
    post_only: bool      # maker-only (GTX) for LIMIT
    client_id: str       # deterministic, for idempotency
    time_in_force: str   # "GTX" (post-only) | "IOC" | "GTC"


@dataclass(frozen=True, slots=True)
class Fill:
    client_id: str
    symbol: str
    side: str
    qty: float           # filled base qty (0.0 if nothing filled)
    price: float         # avg fill price (0.0 if nothing filled)
    fee: float
    status: str          # "FILLED" | "PARTIAL" | "CANCELED" | "REJECTED" | "EXPIRED"


class CryptoExchange(Protocol):
    name: str

    def exchange_info(self) -> dict[str, SymbolFilter]: ...
    def order_book(self, symbol: str) -> OrderBook: ...
    def positions(self) -> list[PerpPosition]: ...
    def account(self) -> CryptoAccount: ...
    def set_leverage(self, symbol: str, leverage: int) -> None: ...
    def ensure_one_way_mode(self) -> None: ...
    def place(self, order: ChildOrder) -> Fill: ...
    def cancel(self, symbol: str, client_id: str) -> None: ...
```

- [ ] **Step 4: Run it (passes) + commit**

Run: `pytest tests/unit/test_crypto_exchange_dtos.py -v` → PASS

```bash
git add src/fxhnt/ports/crypto_exchange.py tests/unit/test_crypto_exchange_dtos.py
git commit -m "feat(exec): crypto_exchange port (DTOs + Protocol)"
```

---

### Task 3: Filter rounding (pure)

**Files:** Create `src/fxhnt/domain/execution/__init__.py` (empty), `src/fxhnt/domain/execution/rounding.py`; Test `tests/unit/test_rounding.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_rounding.py`:

```python
"""Exchange-filter rounding: floor qty to step, round price to tick, min-notional gate."""
from __future__ import annotations

from fxhnt.domain.execution.rounding import meets_min_notional, round_price, round_qty
from fxhnt.ports.crypto_exchange import SymbolFilter

F = SymbolFilter(symbol="X", step_size=0.001, tick_size=0.1, min_notional=5.0)


def test_round_qty_floors_to_step() -> None:
    assert round_qty(0.0037, F) == 0.003          # floor, never round up past intent
    assert round_qty(1.2345, F) == 1.234


def test_round_price_to_tick() -> None:
    assert round_price(100.07, F) == 100.1
    assert round_price(100.04, F) == 100.0


def test_min_notional_gate() -> None:
    assert meets_min_notional(0.06, 100.0, F) is True     # 6 USD >= 5
    assert meets_min_notional(0.04, 100.0, F) is False    # 4 USD < 5


def test_round_qty_below_step_is_zero() -> None:
    assert round_qty(0.0009, F) == 0.0
```

- [ ] **Step 2: Run (fails)**

Run: `pytest tests/unit/test_rounding.py -v` → FAIL (module missing)

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/__init__.py` (empty).

Create `src/fxhnt/domain/execution/rounding.py`:

```python
"""Pure exchange-filter rounding. Quantity floors to step_size (never overshoot the intended size); price
rounds to the nearest tick; min-notional is the exchange's hard floor (~5 USDT on Binance USDⓈ-M)."""
from __future__ import annotations

import math

from fxhnt.ports.crypto_exchange import SymbolFilter


def round_qty(qty: float, f: SymbolFilter) -> float:
    if f.step_size <= 0:
        return qty
    steps = math.floor(abs(qty) / f.step_size + 1e-9)
    return round(steps * f.step_size, 12)


def round_price(price: float, f: SymbolFilter) -> float:
    if f.tick_size <= 0:
        return price
    ticks = round(price / f.tick_size)
    return round(ticks * f.tick_size, 12)


def meets_min_notional(qty: float, price: float, f: SymbolFilter) -> bool:
    return abs(qty) * price >= f.min_notional
```

- [ ] **Step 4: Run (passes) + commit**

Run: `pytest tests/unit/test_rounding.py -v` → PASS

```bash
git add src/fxhnt/domain/execution/__init__.py src/fxhnt/domain/execution/rounding.py tests/unit/test_rounding.py
git commit -m "feat(exec): pure exchange-filter rounding (step/tick/min-notional)"
```

---

### Task 4: latest_target_weights (DRY refactor)

**Files:** Modify `src/fxhnt/application/crypto_momentum_research.py`; Test `tests/unit/test_target_weights.py`

- [ ] **Step 1: Write the failing test**

Create `tests/unit/test_target_weights.py`:

```python
"""latest_target_weights: the current per-symbol target weight vector (dollar-neutral, gross=1)."""
from __future__ import annotations

import numpy as np

from fxhnt.application.crypto_momentum_research import latest_target_weights


def test_dollar_neutral_gross_one() -> None:
    # 12 symbols, rising trends with different slopes -> distinct momentum ranks
    rng = np.random.default_rng(0)
    n = 12
    close = np.cumprod(1.0 + np.linspace(0.0002, 0.004, n) + rng.normal(0, 1e-4, (200, n)), axis=0)
    syms = [f"C{i}" for i in range(n)]
    w = latest_target_weights(close, syms, lookback=30, min_names=10)
    vals = np.array(list(w.values()))
    assert abs(vals.sum()) < 1e-9                       # dollar-neutral
    assert abs(np.abs(vals).sum() - 1.0) < 1e-9         # gross = 1
    assert (vals > 0).any() and (vals < 0).any()        # both long and short


def test_too_few_names_returns_empty() -> None:
    close = np.cumprod(1.0 + np.full((100, 3), 0.001), axis=0)
    assert latest_target_weights(close, ["A", "B", "C"], lookback=30, min_names=10) == {}
```

- [ ] **Step 2: Run (fails)**

Run: `pytest tests/unit/test_target_weights.py -v` → FAIL (no `latest_target_weights`)

- [ ] **Step 3: Refactor + add the function**

In `src/fxhnt/application/crypto_momentum_research.py`, add a shared helper and the public function. Add near `xs_momentum_book`:

```python
def _row_weights(mom_row: np.ndarray, close_row: np.ndarray, *, long_only: bool, min_names: int) -> np.ndarray:
    """Rank-demeaned dollar-neutral (or long-only) weights for ONE cross-section, gross=1."""
    n = close_row.shape[0]
    valid = np.isfinite(mom_row) & np.isfinite(close_row)
    w = np.zeros(n)
    if int(valid.sum()) < min_names:
        return w
    rank = np.argsort(np.argsort(mom_row[valid])) / (valid.sum() - 1) - 0.5
    w[valid] = np.maximum(rank, 0.0) if long_only else rank
    gross = np.abs(w).sum()
    return w / gross if gross > 0 else w


def latest_target_weights(close: np.ndarray, syms: list[str], lookback: int, *,
                          long_only: bool = False, min_names: int = 10) -> dict[str, float]:
    """The CURRENT target weights per symbol from the latest close row's momentum (what to hold now).
    Returns {} if fewer than min_names have a valid lookback. The execution layer consumes this."""
    if close.shape[0] <= lookback:
        return {}
    mom_row = close[-1] / close[-1 - lookback] - 1.0
    w = _row_weights(mom_row, close[-1], long_only=long_only, min_names=min_names)
    return {s: float(wi) for s, wi in zip(syms, w, strict=True) if wi != 0.0}
```

Do NOT change `xs_momentum_book`'s public behavior (leave it; the new helper is additive). Verify the existing crypto tests still pass.

- [ ] **Step 4: Run (passes) + commit**

Run: `pytest tests/unit/test_target_weights.py tests/integration -k crypto -q` → PASS (new + existing crypto tests green)

```bash
git add src/fxhnt/application/crypto_momentum_research.py tests/unit/test_target_weights.py
git commit -m "feat(exec): latest_target_weights — current per-symbol targets for execution"
```

---

### Task 5: binance_ccxt adapter (rate-limit governor + idempotency + testnet)

**Files:** Create `src/fxhnt/adapters/exchange/__init__.py`, `src/fxhnt/adapters/exchange/binance_ccxt.py`; Test `tests/unit/test_binance_ccxt_helpers.py`

> The adapter's network methods are validated on testnet in M5. Here we unit-test the PURE helpers it relies
> on (filter parsing, client-id, the rate-limit token bucket) without ccxt/network.

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_binance_ccxt_helpers.py`:

```python
"""Pure helpers inside the ccxt adapter: market->SymbolFilter parsing, deterministic client ids, token bucket."""
from __future__ import annotations

from fxhnt.adapters.exchange.binance_ccxt import RateBudget, client_id, parse_filter


def test_parse_filter_from_ccxt_market() -> None:
    market = {"id": "BTCUSDT", "limits": {"amount": {"min": 0.001}, "cost": {"min": 5.0}},
              "precision": {"amount": 0.001, "price": 0.1}}
    f = parse_filter("BTCUSDT", market)
    assert f.step_size == 0.001 and f.tick_size == 0.1 and f.min_notional == 5.0


def test_client_id_is_deterministic_and_unique_per_leg() -> None:
    a = client_id("reb20260615", "BTCUSDT", "maker")
    assert a == client_id("reb20260615", "BTCUSDT", "maker")     # deterministic (retry-safe)
    assert a != client_id("reb20260615", "BTCUSDT", "ioc")       # distinct per leg
    assert len(a) <= 36                                          # Binance clientOrderId limit


def test_rate_budget_blocks_when_exhausted() -> None:
    b = RateBudget(max_orders=2)
    assert b.try_order() and b.try_order()
    assert b.try_order() is False                                # 3rd in the window is refused
    b.note_used_weight(2399)
    assert b.weight_ok() is True
    b.note_used_weight(2401)
    assert b.weight_ok() is False                                # over the 2400/min IP weight
```

- [ ] **Step 2: Run (fails)**

Run: `pytest tests/unit/test_binance_ccxt_helpers.py -v` → FAIL (module missing)

- [ ] **Step 3: Implement the adapter**

Create `src/fxhnt/adapters/exchange/__init__.py` (empty).

Create `src/fxhnt/adapters/exchange/binance_ccxt.py`:

```python
"""Binance USDⓈ-M perp exchange adapter (ccxt binanceusdm). Owns ALL exchange I/O. Key production concerns
baked in: a rate-limit governor (2400 weight/min per IP, 1200 orders/min per account; back off on 429, never
hit the 418 ban), idempotent clientOrderId, exchange-filter parsing, and testnet via ccxt sandbox mode."""
from __future__ import annotations

import hashlib
import logging
import time

from fxhnt.ports.crypto_exchange import (
    ChildOrder,
    CryptoAccount,
    Fill,
    OrderBook,
    PerpPosition,
    SymbolFilter,
)

_log = logging.getLogger(__name__)
_MAX_WEIGHT_PER_MIN = 2400
_MAX_ORDERS_PER_MIN = 1200


def parse_filter(symbol: str, market: dict) -> SymbolFilter:
    lim = market.get("limits", {})
    prec = market.get("precision", {})
    return SymbolFilter(
        symbol=symbol,
        step_size=float(prec.get("amount") or lim.get("amount", {}).get("min") or 0.0),
        tick_size=float(prec.get("price") or 0.0),
        min_notional=float((lim.get("cost", {}) or {}).get("min") or 5.0),
    )


def client_id(rebalance_id: str, symbol: str, leg: str) -> str:
    """Deterministic, <=36 chars (Binance clientOrderId limit). Retry-safe: same inputs -> same id."""
    h = hashlib.sha1(f"{rebalance_id}:{symbol}:{leg}".encode()).hexdigest()[:24]
    return f"fxhnt{h}"


class RateBudget:
    """Conservative per-minute governor. Orders are counted locally; weight is updated from the
    X-MBX-USED-WEIGHT response header the adapter reads after each call."""
    def __init__(self, max_orders: int = _MAX_ORDERS_PER_MIN, max_weight: int = _MAX_WEIGHT_PER_MIN) -> None:
        self._max_orders = int(max_orders * 0.9)      # 10% safety margin
        self._max_weight = int(max_weight * 0.9)
        self._orders = 0
        self._used_weight = 0

    def try_order(self) -> bool:
        if self._orders >= self._max_orders:
            return False
        self._orders += 1
        return True

    def note_used_weight(self, used: int) -> None:
        self._used_weight = used

    def weight_ok(self) -> bool:
        return self._used_weight < self._max_weight


class BinanceUsdmExchange:
    """Implements the CryptoExchange Protocol over ccxt binanceusdm."""
    name = "binanceusdm"

    def __init__(self, api_key: str, api_secret: str, *, testnet: bool = True) -> None:
        import ccxt
        self._x = ccxt.binanceusdm({
            "apiKey": api_key, "secret": api_secret, "enableRateLimit": True,
            "options": {"defaultType": "future"},
        })
        if testnet:
            self._x.set_sandbox_mode(True)
        self._budget = RateBudget()

    def _backoff(self, fn, *a, **kw):
        for attempt in range(5):
            try:
                out = fn(*a, **kw)
                used = (self._x.last_response_headers or {}).get("X-MBX-USED-WEIGHT-1M")
                if used:
                    self._budget.note_used_weight(int(used))
                return out
            except Exception as e:                       # noqa: BLE001 — ccxt raises many types; classify by msg
                msg = str(e).lower()
                if "429" in msg or "rate limit" in msg or "-1003" in msg:
                    wait = 2 ** attempt
                    _log.warning("rate limited; backoff %ss (attempt %d)", wait, attempt + 1)
                    time.sleep(wait)
                    continue
                raise
        raise RuntimeError("rate-limit backoff exhausted")

    def exchange_info(self) -> dict[str, SymbolFilter]:
        markets = self._backoff(self._x.load_markets)
        out: dict[str, SymbolFilter] = {}
        for m in markets.values():
            if m.get("swap") and m.get("quote") == "USDT" and m.get("active"):
                out[m["id"]] = parse_filter(m["id"], m)
        return out

    def order_book(self, symbol: str) -> OrderBook:
        ob = self._backoff(self._x.fetch_order_book, symbol, 5)
        bid, bq = (ob["bids"][0] if ob["bids"] else (0.0, 0.0))
        ask, aq = (ob["asks"][0] if ob["asks"] else (0.0, 0.0))
        return OrderBook(symbol=symbol, bid=float(bid), ask=float(ask), bid_qty=float(bq), ask_qty=float(aq))

    def positions(self) -> list[PerpPosition]:
        ps = self._backoff(self._x.fetch_positions)
        out = []
        for p in ps:
            amt = float(p.get("contracts") or 0.0) * (1 if p.get("side") == "long" else -1)
            if amt != 0.0:
                out.append(PerpPosition(symbol=p["info"].get("symbol", p["symbol"]), qty=amt,
                                        entry_price=float(p.get("entryPrice") or 0.0),
                                        leverage=int(float(p.get("leverage") or 1))))
        return out

    def account(self) -> CryptoAccount:
        bal = self._backoff(self._x.fetch_balance)
        nlv = float(bal["info"].get("totalMarginBalance") or bal.get("USDT", {}).get("total") or 0.0)
        avail = float(bal["info"].get("availableBalance") or bal.get("USDT", {}).get("free") or 0.0)
        pos = {p.symbol: p.qty for p in self.positions()}
        return CryptoAccount(nlv=nlv, available_balance=avail, positions=pos)

    def set_leverage(self, symbol: str, leverage: int) -> None:
        try:
            self._backoff(self._x.set_leverage, leverage, symbol)
        except Exception as e:                            # noqa: BLE001 — idempotent; already-set is fine
            _log.debug("set_leverage %s=%d: %s", symbol, leverage, e)

    def ensure_one_way_mode(self) -> None:
        try:
            self._backoff(self._x.set_position_mode, False)   # hedged=False -> one-way
        except Exception as e:                            # noqa: BLE001 — already one-way raises; ignore
            _log.debug("set_position_mode one-way: %s", e)

    def place(self, order: ChildOrder) -> Fill:
        if not self._budget.try_order() or not self._budget.weight_ok():
            _log.warning("rate budget exhausted; skipping %s", order.client_id)
            return Fill(order.client_id, order.symbol, order.side, 0.0, 0.0, 0.0, "REJECTED")
        params = {"newClientOrderId": order.client_id, "reduceOnly": order.reduce_only}
        if order.order_type == "LIMIT":
            params["timeInForce"] = order.time_in_force   # "GTX" post-only or "IOC"
        try:
            r = self._backoff(self._x.create_order, order.symbol, order.order_type.lower(), order.side.lower(),
                              order.qty, order.price, params)
        except Exception as e:                            # noqa: BLE001 — log + report REJECTED, never crash batch
            _log.warning("place %s rejected: %s", order.client_id, e)
            return Fill(order.client_id, order.symbol, order.side, 0.0, 0.0, 0.0, "REJECTED")
        filled = float(r.get("filled") or 0.0)
        status = "FILLED" if filled >= order.qty - 1e-12 else ("PARTIAL" if filled > 0 else
                 (r.get("status") or "CANCELED").upper())
        return Fill(order.client_id, order.symbol, order.side, filled, float(r.get("average") or 0.0),
                    float((r.get("fee") or {}).get("cost") or 0.0), status)

    def cancel(self, symbol: str, client_id: str) -> None:
        try:
            self._backoff(self._x.cancel_order, client_id, symbol, {"origClientOrderId": client_id})
        except Exception as e:                            # noqa: BLE001 — already gone is fine
            _log.debug("cancel %s: %s", client_id, e)
```

- [ ] **Step 4: Run (passes) + commit**

Run: `pytest tests/unit/test_binance_ccxt_helpers.py -v` → PASS

```bash
git add src/fxhnt/adapters/exchange/ tests/unit/test_binance_ccxt_helpers.py
git commit -m "feat(exec): binance_ccxt adapter — rate-limit governor, idempotent ids, testnet sandbox"
```

---

# Milestone 2 — Smart router (pure)

### Task 6: smart_router.plan_orders

**Files:** Create `src/fxhnt/domain/execution/smart_router.py`; Test `tests/unit/test_smart_router.py`

> v1 router = pure PLANNING: per symbol, size the order (capped by participation×ADV and depth), round to
> filters, skip dust/min-notional, set the maker limit price @ touch and the abort price (mid ± max_slippage),
> and mark reduce_only when the trade reduces an existing position. The stateful maker→IOC loop lives in the
> orchestrator (Task 11). Intraday time-slicing is a measured follow-up (TCA tells us if it's needed).

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_smart_router.py`:

```python
"""smart_router.plan_orders: sizing, caps, dust/min-notional skip, maker+abort pricing, reduce_only."""
from __future__ import annotations

from fxhnt.domain.execution.smart_router import RouteInput, plan_orders
from fxhnt.ports.crypto_exchange import OrderBook, SymbolFilter

F = SymbolFilter(symbol="X", step_size=0.001, tick_size=0.1, min_notional=5.0)
OB = OrderBook(symbol="X", bid=100.0, ask=100.2, bid_qty=10.0, ask_qty=10.0)   # depth ~1000 USD/side


def _inp(delta_usd, pos_qty=0.0, adv=1e9, max_slip=25.0, part=0.5, depth=0.5):
    return RouteInput(symbol="X", delta_usd=delta_usd, current_qty=pos_qty, filt=F, book=OB,
                      adv_usd=adv, participation_rate=part, depth_cap=depth, max_slippage_bps=max_slip)


def test_buy_maker_at_bid_with_abort() -> None:
    p = plan_orders([_inp(500.0)])[0]
    assert p.side == "BUY" and p.skip_reason is None
    assert p.price == 100.0                                   # maker buy posts at the bid (touch)
    assert p.qty == round(5.0, 3) or p.qty > 0               # ~500/100 = 5 base
    assert p.abort_price > OB.mid                             # buy abort is above mid (max adverse)


def test_dust_below_min_notional_skipped() -> None:
    p = plan_orders([_inp(3.0)])[0]
    assert p.skip_reason == "min_notional"


def test_participation_and_depth_cap_limit_size() -> None:
    # huge target, ADV tiny -> participation cap binds
    p = plan_orders([_inp(1_000_000.0, adv=2000.0, part=0.05, depth=0.5)])[0]
    assert p.qty * OB.mid <= 0.05 * 2000.0 + 1e-6            # capped to participation×ADV (=100 USD)
    assert p.capped is True


def test_reduce_only_when_trade_reduces_position() -> None:
    # short 10 base; a BUY of 2 base reduces -> reduce_only
    p = plan_orders([_inp(200.0, pos_qty=-10.0)])[0]
    assert p.side == "BUY" and p.reduce_only is True


def test_sell_maker_at_ask() -> None:
    p = plan_orders([_inp(-500.0)])[0]
    assert p.side == "SELL" and p.price == 100.2 and p.abort_price < OB.mid
```

- [ ] **Step 2: Run (fails)**

Run: `pytest tests/unit/test_smart_router.py -v` → FAIL (module missing)

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/smart_router.py`:

```python
"""Pure liquidity-aware order planner. For each per-symbol dollar delta, produce ONE sized PlannedOrder:
capped by participation×ADV and order-book depth, rounded to exchange filters, dust/min-notional skipped,
priced maker-at-touch with an arrival-mid abort price, and flagged reduce_only when it reduces a position.
No I/O, no state — the orchestrator runs the maker→IOC loop using these plans."""
from __future__ import annotations

from dataclasses import dataclass

from fxhnt.domain.execution.rounding import meets_min_notional, round_price, round_qty
from fxhnt.ports.crypto_exchange import OrderBook, SymbolFilter


@dataclass(frozen=True, slots=True)
class RouteInput:
    symbol: str
    delta_usd: float          # signed target change in USD notional (+ = buy, - = sell)
    current_qty: float        # signed current base position
    filt: SymbolFilter
    book: OrderBook
    adv_usd: float            # average daily volume in USD (capacity)
    participation_rate: float
    depth_cap: float
    max_slippage_bps: float


@dataclass(frozen=True, slots=True)
class PlannedOrder:
    symbol: str
    side: str                 # "BUY" | "SELL"
    qty: float                # rounded base qty (>0)
    price: float              # maker limit @ touch
    abort_price: float        # IOC cross aborts beyond this (arrival mid ± max_slippage)
    reduce_only: bool
    capped: bool              # size was limited by participation/depth
    skip_reason: str | None   # None = actionable; else "dust"/"min_notional"/"no_book"


def plan_orders(inputs: list[RouteInput]) -> list[PlannedOrder]:
    return [_plan_one(i) for i in inputs]


def _plan_one(i: RouteInput) -> PlannedOrder:
    side = "BUY" if i.delta_usd > 0 else "SELL"
    mid = i.book.mid
    if mid <= 0 or i.book.bid <= 0 or i.book.ask <= 0:
        return PlannedOrder(i.symbol, side, 0.0, 0.0, 0.0, False, False, "no_book")

    target_usd = abs(i.delta_usd)
    depth_usd = (i.book.bid_qty * i.book.bid) if side == "SELL" else (i.book.ask_qty * i.book.ask)
    cap_usd = min(i.participation_rate * i.adv_usd, i.depth_cap * depth_usd)
    capped = target_usd > cap_usd
    use_usd = min(target_usd, cap_usd)

    qty = round_qty(use_usd / mid, i.filt)
    if qty <= 0:
        return PlannedOrder(i.symbol, side, 0.0, 0.0, 0.0, False, capped, "dust")
    if not meets_min_notional(qty, mid, i.filt):
        return PlannedOrder(i.symbol, side, 0.0, 0.0, 0.0, False, capped, "min_notional")

    price = round_price(i.book.bid if side == "BUY" else i.book.ask, i.filt)   # maker @ touch
    slip = i.max_slippage_bps / 1e4
    abort = round_price(mid * (1 + slip) if side == "BUY" else mid * (1 - slip), i.filt)
    reduce_only = (i.current_qty > 0 and side == "SELL" and qty <= i.current_qty) or \
                  (i.current_qty < 0 and side == "BUY" and qty <= abs(i.current_qty))
    return PlannedOrder(i.symbol, side, qty, price, abort, reduce_only, capped, None)
```

- [ ] **Step 4: Run (passes) + commit**

Run: `pytest tests/unit/test_smart_router.py -v` → PASS

```bash
git add src/fxhnt/domain/execution/smart_router.py tests/unit/test_smart_router.py
git commit -m "feat(exec): pure smart router (sizing, caps, maker+abort pricing, reduce-only)"
```

---

# Milestone 3 — Risk layer (pure)

### Task 7: capacity (ADV caps)

**Files:** Create `src/fxhnt/domain/execution/capacity.py`; Test `tests/unit/test_capacity.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_capacity.py`:

```python
"""capacity.cap_weights: never let a symbol's target notional exceed per_symbol_cap×NLV or a share of ADV."""
from __future__ import annotations

from fxhnt.domain.execution.capacity import cap_weights


def test_per_symbol_cap_binds() -> None:
    w = {"A": 0.5, "B": -0.5}
    out = cap_weights(w, nlv=10_000.0, adv_usd={"A": 1e9, "B": 1e9}, per_symbol_cap=0.05, participation_rate=1.0)
    assert abs(out["A"]) <= 0.05 + 1e-9 and abs(out["B"]) <= 0.05 + 1e-9


def test_adv_cap_binds_for_thin_symbol() -> None:
    # B is thin: ADV 1000 USD, participation 0.05 -> max 50 USD; at NLV 10k that's weight 0.005
    out = cap_weights({"A": 0.5, "B": 0.5}, nlv=10_000.0, adv_usd={"A": 1e9, "B": 1000.0},
                      per_symbol_cap=1.0, participation_rate=0.05)
    assert abs(out["B"]) <= 0.005 + 1e-9
    assert abs(out["A"]) > abs(out["B"])
```

- [ ] **Step 2: Run (fails)** → `pytest tests/unit/test_capacity.py -v`

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/capacity.py`:

```python
"""Pure capacity caps: a target weight may not imply a notional above per_symbol_cap×NLV, nor above
participation_rate×ADV for that symbol (don't be a disproportionate share of a thin market)."""
from __future__ import annotations


def cap_weights(weights: dict[str, float], *, nlv: float, adv_usd: dict[str, float],
                per_symbol_cap: float, participation_rate: float) -> dict[str, float]:
    if nlv <= 0:
        return {s: 0.0 for s in weights}
    out: dict[str, float] = {}
    for s, w in weights.items():
        max_w = per_symbol_cap
        adv = adv_usd.get(s)
        if adv is not None and adv > 0:
            max_w = min(max_w, participation_rate * adv / nlv)
        out[s] = max(-max_w, min(max_w, w))
    return out
```

- [ ] **Step 4: Run (passes) + commit**

```bash
git add src/fxhnt/domain/execution/capacity.py tests/unit/test_capacity.py
git commit -m "feat(exec): pure capacity caps (per-symbol + ADV)"
```

---

### Task 8: risk_gates (gates + edge-decay×Kelly scaler)

**Files:** Create `src/fxhnt/domain/execution/risk_gates.py`; Test `tests/unit/test_risk_gates.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_risk_gates.py`:

```python
"""risk_gates: hard pre-trade gate + dynamic gross scaler (edge-decay θ × Kelly fraction)."""
from __future__ import annotations

from fxhnt.domain.execution.risk_gates import GateConfig, gross_scaler, pretrade_block


def _cfg(**kw):
    base = dict(allow_live=True, kill_switch=False, testnet=False, gross_cap=1.0, session_dd=-0.1)
    base.update(kw)
    return GateConfig(**base)


def test_kill_switch_blocks() -> None:
    assert pretrade_block(_cfg(kill_switch=True), session_pnl_pct=0.0) == "kill_switch"


def test_live_requires_allow_live() -> None:
    assert pretrade_block(_cfg(allow_live=False, testnet=False), session_pnl_pct=0.0) == "allow_live"


def test_session_drawdown_breaker() -> None:
    assert pretrade_block(_cfg(session_dd=-0.1), session_pnl_pct=-0.12) == "session_drawdown"


def test_clear_when_safe() -> None:
    assert pretrade_block(_cfg(), session_pnl_pct=-0.02) is None


def test_testnet_bypasses_allow_live() -> None:
    assert pretrade_block(_cfg(allow_live=False, testnet=True), session_pnl_pct=0.0) is None


def test_gross_scaler_shrinks_with_decay_and_kelly() -> None:
    full = gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
    assert abs(full - 0.25) < 1e-9                         # kelly capped at 0.25, theta=1 -> 0.25
    decayed = gross_scaler(edge_theta=0.2, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
    assert decayed < full                                  # edge decay shrinks gross
    assert gross_scaler(edge_theta=0.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0) == 0.0
```

- [ ] **Step 2: Run (fails)** → `pytest tests/unit/test_risk_gates.py -v`

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/risk_gates.py`:

```python
"""Pure risk gates. `pretrade_block` returns a blocking reason (or None) for the hard gates; `gross_scaler`
returns the [0,1+] multiplier applied to target gross = clamp(edge_theta) × min(kelly_fraction, kelly_cap),
bounded by gross_cap. Edge decays -> gross shrinks; edge dies (theta→0) -> flat."""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class GateConfig:
    allow_live: bool
    kill_switch: bool
    testnet: bool
    gross_cap: float
    session_dd: float        # negative threshold, e.g. -0.10


def pretrade_block(cfg: GateConfig, *, session_pnl_pct: float) -> str | None:
    if cfg.kill_switch:
        return "kill_switch"
    if not cfg.testnet and not cfg.allow_live:
        return "allow_live"
    if session_pnl_pct <= cfg.session_dd:
        return "session_drawdown"
    return None


def gross_scaler(*, edge_theta: float, kelly_fraction: float, kelly_cap: float, gross_cap: float) -> float:
    theta = max(0.0, min(1.0, edge_theta))
    kelly = max(0.0, min(kelly_cap, kelly_fraction))
    return min(gross_cap, theta * kelly)
```

- [ ] **Step 4: Run (passes) + commit**

```bash
git add src/fxhnt/domain/execution/risk_gates.py tests/unit/test_risk_gates.py
git commit -m "feat(exec): pure risk gates + edge-decay×Kelly gross scaler"
```

---

# Milestone 4 — Orchestrator + observability

### Task 9: tca (implementation shortfall)

**Files:** Create `src/fxhnt/domain/execution/tca.py`; Test `tests/unit/test_tca.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_tca.py`:

```python
"""tca.shortfall_bps: signed implementation shortfall of a fill vs the arrival mid, in bps (cost positive)."""
from __future__ import annotations

from fxhnt.domain.execution.tca import shortfall_bps


def test_buy_above_mid_is_positive_cost() -> None:
    assert abs(shortfall_bps("BUY", arrival_mid=100.0, fill_price=100.1) - 10.0) < 1e-6


def test_sell_below_mid_is_positive_cost() -> None:
    assert abs(shortfall_bps("SELL", arrival_mid=100.0, fill_price=99.9) - 10.0) < 1e-6


def test_no_fill_is_zero() -> None:
    assert shortfall_bps("BUY", arrival_mid=100.0, fill_price=0.0) == 0.0
```

- [ ] **Step 2: Run (fails)** → `pytest tests/unit/test_tca.py -v`

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/tca.py`:

```python
"""Pure transaction-cost analysis: implementation shortfall of a fill vs the arrival (decision) mid, in bps.
Positive = cost (we paid up to buy / sold cheap). Tells us whether execution is eating the alpha."""
from __future__ import annotations


def shortfall_bps(side: str, *, arrival_mid: float, fill_price: float) -> float:
    if fill_price <= 0 or arrival_mid <= 0:
        return 0.0
    raw = (fill_price - arrival_mid) / arrival_mid
    signed = raw if side == "BUY" else -raw
    return signed * 1e4
```

- [ ] **Step 4: Run (passes) + commit**

```bash
git add src/fxhnt/domain/execution/tca.py tests/unit/test_tca.py
git commit -m "feat(exec): pure TCA (implementation shortfall bps)"
```

---

### Task 10: reconciliation

**Files:** Create `src/fxhnt/domain/execution/reconciliation.py`; Test `tests/unit/test_reconciliation.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/unit/test_reconciliation.py`:

```python
"""reconciliation.diff_positions: report symbols whose actual exchange qty drifts from intended beyond tol."""
from __future__ import annotations

from fxhnt.domain.execution.reconciliation import diff_positions


def test_within_tolerance_is_clean() -> None:
    drift = diff_positions(intended={"A": 1.0, "B": -2.0}, actual={"A": 1.0005, "B": -2.0},
                           tol_frac=0.01)
    assert drift == {}


def test_drift_beyond_tolerance_flagged() -> None:
    drift = diff_positions(intended={"A": 1.0}, actual={"A": 0.5}, tol_frac=0.01)
    assert "A" in drift and abs(drift["A"] - (-0.5)) < 1e-9     # actual - intended


def test_unexpected_position_flagged() -> None:
    drift = diff_positions(intended={}, actual={"Z": 3.0}, tol_frac=0.01)
    assert "Z" in drift
```

- [ ] **Step 2: Run (fails)** → `pytest tests/unit/test_reconciliation.py -v`

- [ ] **Step 3: Implement**

Create `src/fxhnt/domain/execution/reconciliation.py`:

```python
"""Pure post-trade reconciliation: compare intended vs actual signed positions; return the drift (actual -
intended) for any symbol off by more than tol_frac of the intended size (or any wholly-unexpected position).
A non-empty result means HALT — never trade past unexplained drift."""
from __future__ import annotations


def diff_positions(intended: dict[str, float], actual: dict[str, float], *, tol_frac: float) -> dict[str, float]:
    drift: dict[str, float] = {}
    for sym in set(intended) | set(actual):
        want = intended.get(sym, 0.0)
        have = actual.get(sym, 0.0)
        tol = max(abs(want) * tol_frac, 1e-9)
        if abs(have - want) > tol:
            drift[sym] = have - want
    return drift
```

- [ ] **Step 4: Run (passes) + commit**

```bash
git add src/fxhnt/domain/execution/reconciliation.py tests/unit/test_reconciliation.py
git commit -m "feat(exec): pure position reconciliation (drift detection -> halt)"
```

---

### Task 11: ExecFillRow + orchestrator

**Files:** Modify `src/fxhnt/adapters/persistence/cockpit_models.py`; Create `src/fxhnt/adapters/persistence/exec_store.py`, `src/fxhnt/application/crypto_execution.py`; Test `tests/integration/test_crypto_execution.py`

- [ ] **Step 1: Write the failing test (with a fake exchange)**

Create `tests/integration/test_crypto_execution.py`:

```python
"""Orchestrator end-to-end with a FAKE exchange: targets -> gates -> router -> place -> reconcile -> persist.
Maker fills here (fake fills the maker leg), so no IOC needed; reconciliation is clean."""
from __future__ import annotations

from fxhnt.adapters.persistence.exec_store import ExecStore
from fxhnt.application.crypto_execution import CryptoExecutionService, ExecConfig
from fxhnt.ports.crypto_exchange import (
    ChildOrder, CryptoAccount, Fill, OrderBook, PerpPosition, SymbolFilter,
)


class FakeExchange:
    name = "fake"
    def __init__(self):
        self._pos: dict[str, float] = {}
        self.placed: list[ChildOrder] = []
    def exchange_info(self): return {s: SymbolFilter(s, 0.001, 0.1, 5.0) for s in ("AAA", "BBB")}
    def order_book(self, s): return OrderBook(s, 100.0, 100.1, 100.0, 100.0)   # deep book
    def positions(self): return [PerpPosition(s, q, 100.0, 3) for s, q in self._pos.items() if q]
    def account(self): return CryptoAccount(nlv=10_000.0, available_balance=10_000.0, positions=dict(self._pos))
    def set_leverage(self, s, l): pass
    def ensure_one_way_mode(self): pass
    def place(self, o: ChildOrder) -> Fill:
        self.placed.append(o)
        self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + (o.qty if o.side == "BUY" else -o.qty)
        return Fill(o.client_id, o.symbol, o.side, o.qty, o.price, 0.0, "FILLED")
    def cancel(self, s, cid): pass


def _cfg(**kw):
    base = dict(testnet=True, allow_live=False, kill_switch=False, leverage=3, gross_cap=1.0,
                per_symbol_cap=0.5, participation_rate=1.0, depth_cap=1.0, max_slippage_bps=25.0,
                hysteresis=0.001, kelly_cap=0.25, maker_wait_s=0.0)
    base.update(kw); return ExecConfig(**base)


def test_dry_run_places_nothing() -> None:
    fx = FakeExchange()
    svc = CryptoExecutionService(fx, ExecStore("sqlite://"), _cfg())
    plan = svc.rebalance({"AAA": 0.5, "BBB": -0.5}, adv_usd={"AAA": 1e9, "BBB": 1e9},
                         edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r1", execute=False)
    assert plan.n_orders > 0 and fx.placed == []           # planned, not placed


def test_execute_places_and_reconciles_clean() -> None:
    fx = FakeExchange()
    store = ExecStore("sqlite://")
    svc = CryptoExecutionService(fx, store, _cfg())
    plan = svc.rebalance({"AAA": 0.5, "BBB": -0.5}, adv_usd={"AAA": 1e9, "BBB": 1e9},
                         edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r1", execute=True)
    assert len(fx.placed) == 2 and plan.drift == {}        # both legs placed, reconciliation clean
    assert plan.halted is False
    assert len(store.fills("r1")) == 2                     # fills persisted


def test_kill_switch_halts_before_placing() -> None:
    fx = FakeExchange()
    svc = CryptoExecutionService(fx, ExecStore("sqlite://"), _cfg(kill_switch=True))
    plan = svc.rebalance({"AAA": 0.5}, adv_usd={"AAA": 1e9}, edge_theta=1.0, kelly_fraction=1.0,
                         rebalance_id="r1", execute=True)
    assert plan.halted is True and plan.block_reason == "kill_switch" and fx.placed == []
```

- [ ] **Step 2: Run (fails)** → `pytest tests/integration/test_crypto_execution.py -v`

- [ ] **Step 3: Add the fills table**

In `src/fxhnt/adapters/persistence/cockpit_models.py`, add (alongside the other `CockpitBase` models):

```python
class ExecFillRow(CockpitBase):
    __tablename__ = "exec_fills"
    client_id: Mapped[str] = mapped_column(String(64), primary_key=True)
    rebalance_id: Mapped[str] = mapped_column(String(48), index=True)
    symbol: Mapped[str] = mapped_column(String(32))
    side: Mapped[str] = mapped_column(String(4))
    qty: Mapped[float] = mapped_column(Float)
    price: Mapped[float] = mapped_column(Float)
    fee: Mapped[float] = mapped_column(Float)
    status: Mapped[str] = mapped_column(String(12))
    shortfall_bps: Mapped[float] = mapped_column(Float)
    at: Mapped[dt.datetime] = mapped_column(DateTime)
```

- [ ] **Step 4: Implement the store**

Create `src/fxhnt/adapters/persistence/exec_store.py`:

```python
"""Persist execution fills + TCA into the cockpit DB (so the dashboard can show live execution + cost)."""
from __future__ import annotations

import datetime as dt

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

from fxhnt.adapters.persistence.cockpit_models import CockpitBase, ExecFillRow


class ExecStore:
    def __init__(self, dsn: str) -> None:
        kw = {"future": True}
        if dsn == "sqlite://":
            kw |= {"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
        self._engine = create_engine(dsn, **kw)
        CockpitBase.metadata.create_all(self._engine)

    def record(self, *, client_id: str, rebalance_id: str, symbol: str, side: str, qty: float,
               price: float, fee: float, status: str, shortfall_bps: float, at: dt.datetime) -> None:
        with Session(self._engine) as s:
            s.merge(ExecFillRow(client_id=client_id, rebalance_id=rebalance_id, symbol=symbol, side=side,
                                qty=qty, price=price, fee=fee, status=status, shortfall_bps=shortfall_bps, at=at))
            s.commit()

    def fills(self, rebalance_id: str) -> list[ExecFillRow]:
        stmt = select(ExecFillRow).where(ExecFillRow.rebalance_id == rebalance_id)
        with Session(self._engine) as s:
            return list(s.scalars(stmt))
```

- [ ] **Step 5: Implement the orchestrator**

Create `src/fxhnt/application/crypto_execution.py`:

```python
"""Crypto execution orchestrator. Wires: target weights -> capacity caps -> edge-decay×Kelly gross scaler ->
target $ -> deltas vs live positions -> hard gates -> smart router -> maker→IOC place loop (rate-limited via
the adapter) -> TCA + reconciliation -> persist. Dry-run plans without placing. Halts on any gate or drift."""
from __future__ import annotations

import datetime as dt
import time
from dataclasses import dataclass

from fxhnt.adapters.exchange.binance_ccxt import client_id
from fxhnt.adapters.persistence.exec_store import ExecStore
from fxhnt.domain.execution.capacity import cap_weights
from fxhnt.domain.execution.reconciliation import diff_positions
from fxhnt.domain.execution.risk_gates import GateConfig, gross_scaler, pretrade_block
from fxhnt.domain.execution.smart_router import RouteInput, plan_orders
from fxhnt.domain.execution.tca import shortfall_bps
from fxhnt.ports.crypto_exchange import ChildOrder, CryptoExchange


@dataclass(frozen=True, slots=True)
class ExecConfig:
    testnet: bool
    allow_live: bool
    kill_switch: bool
    leverage: int
    gross_cap: float
    per_symbol_cap: float
    participation_rate: float
    depth_cap: float
    max_slippage_bps: float
    hysteresis: float
    kelly_cap: float
    maker_wait_s: float
    session_dd: float = -0.10
    recon_tol_frac: float = 0.02


@dataclass
class RebalancePlan:
    rebalance_id: str
    n_orders: int
    halted: bool
    block_reason: str | None
    drift: dict[str, float]


class CryptoExecutionService:
    def __init__(self, exchange: CryptoExchange, store: ExecStore, cfg: ExecConfig) -> None:
        self._x = exchange
        self._store = store
        self._cfg = cfg

    def rebalance(self, weights: dict[str, float], *, adv_usd: dict[str, float], edge_theta: float,
                  kelly_fraction: float, rebalance_id: str, execute: bool,
                  session_pnl_pct: float = 0.0) -> RebalancePlan:
        cfg = self._cfg
        block = pretrade_block(
            GateConfig(allow_live=cfg.allow_live, kill_switch=cfg.kill_switch, testnet=cfg.testnet,
                       gross_cap=cfg.gross_cap, session_dd=cfg.session_dd),
            session_pnl_pct=session_pnl_pct)
        if block and execute:
            return RebalancePlan(rebalance_id, 0, halted=True, block_reason=block, drift={})

        acct = self._x.account()
        filters = self._x.exchange_info()
        capped = cap_weights(weights, nlv=acct.nlv, adv_usd=adv_usd,
                             per_symbol_cap=cfg.per_symbol_cap, participation_rate=cfg.participation_rate)
        scale = gross_scaler(edge_theta=edge_theta, kelly_fraction=kelly_fraction,
                             kelly_cap=cfg.kelly_cap, gross_cap=cfg.gross_cap)

        inputs: list[RouteInput] = []
        for sym, w in capped.items():
            if sym not in filters:
                continue
            target_usd = w * scale * acct.nlv
            cur_qty = acct.positions.get(sym, 0.0)
            book = self._x.order_book(sym)
            cur_usd = cur_qty * book.mid
            delta_usd = target_usd - cur_usd
            if abs(delta_usd) < cfg.hysteresis * acct.nlv:
                continue
            inputs.append(RouteInput(symbol=sym, delta_usd=delta_usd, current_qty=cur_qty, filt=filters[sym],
                                     book=book, adv_usd=adv_usd.get(sym, 0.0),
                                     participation_rate=cfg.participation_rate, depth_cap=cfg.depth_cap,
                                     max_slippage_bps=cfg.max_slippage_bps))

        plans = [p for p in plan_orders(inputs) if p.skip_reason is None]
        if not execute:
            return RebalancePlan(rebalance_id, len(plans), halted=False, block_reason=None, drift={})

        self._x.ensure_one_way_mode()
        intended = dict(acct.positions)
        for p in plans:
            self._x.set_leverage(p.symbol, cfg.leverage)
            arrival_mid = (p.price + p.abort_price) / 2.0
            # maker leg (post-only @ touch)
            mk = ChildOrder(p.symbol, p.side, p.qty, "LIMIT", p.price, p.reduce_only, True,
                            client_id(rebalance_id, p.symbol, "maker"), "GTX")
            f = self._x.place(mk)
            if f.status != "FILLED" and cfg.maker_wait_s > 0:
                time.sleep(cfg.maker_wait_s)
            if f.status not in ("FILLED",):
                self._x.cancel(p.symbol, mk.client_id)
                remaining = round(p.qty - f.qty, 12)
                if remaining > 0:
                    ioc = ChildOrder(p.symbol, p.side, remaining, "LIMIT", p.abort_price, p.reduce_only, False,
                                     client_id(rebalance_id, p.symbol, "ioc"), "IOC")
                    f2 = self._x.place(ioc)
                    self._persist(rebalance_id, ioc, f2, arrival_mid)
            self._persist(rebalance_id, mk, f, arrival_mid)
            signed = (f.qty if p.side == "BUY" else -f.qty)
            intended[p.symbol] = intended.get(p.symbol, 0.0) + signed

        actual = {pp.symbol: pp.qty for pp in self._x.positions()}
        drift = diff_positions(intended, actual, tol_frac=cfg.recon_tol_frac)
        return RebalancePlan(rebalance_id, len(plans), halted=bool(drift), block_reason=None, drift=drift)

    def _persist(self, rebalance_id, order, fill, arrival_mid) -> None:
        self._store.record(client_id=fill.client_id, rebalance_id=rebalance_id, symbol=fill.symbol,
                           side=fill.side, qty=fill.qty, price=fill.price, fee=fill.fee, status=fill.status,
                           shortfall_bps=shortfall_bps(fill.side, arrival_mid=arrival_mid, fill_price=fill.price),
                           at=dt.datetime.now(dt.UTC).replace(tzinfo=None))
```

- [ ] **Step 6: Run (passes) + commit**

Run: `pytest tests/integration/test_crypto_execution.py -v` → PASS (3 passed). Then `pytest -q` (whole suite green).

```bash
git add src/fxhnt/adapters/persistence/cockpit_models.py src/fxhnt/adapters/persistence/exec_store.py src/fxhnt/application/crypto_execution.py tests/integration/test_crypto_execution.py
git commit -m "feat(exec): orchestrator (gates->router->maker/IOC->reconcile->persist) + fills store"
```

---

### Task 12: CLI commands

**Files:** Modify `src/fxhnt/cli.py`

- [ ] **Step 1: Read the CLI head** — `sed -n '1,40p' src/fxhnt/cli.py` to confirm the `app`/`get_settings` names; adapt the code below to match.

- [ ] **Step 2: Add module-level imports + commands**

Add near the other top-level imports in `src/fxhnt/cli.py`:

```python
from fxhnt.adapters.exchange.binance_ccxt import BinanceUsdmExchange
from fxhnt.application.crypto_execution import CryptoExecutionService, ExecConfig
```

Append the commands (adapt `get_settings()` to the file's accessor):

```python
def _crypto_exec_service():
    from fxhnt.adapters.persistence.exec_store import ExecStore
    s = get_settings()
    b = s.binance
    ex = BinanceUsdmExchange(b.api_key, b.api_secret, testnet=b.testnet)
    cfg = ExecConfig(testnet=b.testnet, allow_live=b.allow_live, kill_switch=b.kill_switch, leverage=b.leverage,
                     gross_cap=b.gross_cap, per_symbol_cap=b.per_symbol_cap, participation_rate=b.participation_rate,
                     depth_cap=b.depth_cap, max_slippage_bps=b.max_slippage_bps, hysteresis=b.hysteresis,
                     kelly_cap=b.kelly_cap, maker_wait_s=b.maker_wait_s)
    return CryptoExecutionService(ex, ExecStore(s.operational_dsn), cfg), ex


@app.command("crypto-rebalance")
def crypto_rebalance(
    data_dir: str = typer.Option(..., "--data-dir", help="dir with crypto_pit/ for target weights"),
    lookback: int = typer.Option(30, "--lookback"),
    execute: bool = typer.Option(False, "--execute/--dry-run"),
) -> None:
    """Rebalance the crypto momentum book on the exchange (testnet unless live gates are open). Dry-run by default."""
    import datetime as dt

    import numpy as np

    from fxhnt.application.crypto_momentum_research import latest_target_weights, load_crypto_panel
    days, syms, close, _ = load_crypto_panel(f"{data_dir}/crypto_pit")
    weights = latest_target_weights(close, syms, lookback)
    # ADV (USD) per symbol from the last 30 rows of close × a volume proxy is not in the panel; use NLV-neutral
    # large ADV so capacity falls back to per_symbol_cap unless a real ADV map is supplied (calibrated in M5).
    adv = {s: 1e12 for s in weights}
    svc, _ = _crypto_exec_service()
    rid = "reb" + dt.datetime.now(dt.UTC).strftime("%Y%m%d%H%M")
    plan = svc.rebalance(weights, adv_usd=adv, edge_theta=1.0, kelly_fraction=1.0, rebalance_id=rid,
                         execute=execute)
    typer.echo(f"[{rid}] {'EXECUTED' if execute else 'DRY-RUN'} orders={plan.n_orders} "
               f"halted={plan.halted} reason={plan.block_reason} drift={plan.drift}")


@app.command("crypto-positions")
def crypto_positions() -> None:
    """Show current exchange positions + account NLV."""
    _, ex = _crypto_exec_service()
    acct = ex.account()
    typer.echo(f"NLV ${acct.nlv:,.2f}  avail ${acct.available_balance:,.2f}  positions={acct.positions}")


@app.command("crypto-flatten")
def crypto_flatten() -> None:
    """Kill-switch: close ALL crypto positions with reduce-only market orders."""
    from fxhnt.adapters.exchange.binance_ccxt import client_id
    from fxhnt.ports.crypto_exchange import ChildOrder
    _, ex = _crypto_exec_service()
    for p in ex.positions():
        side = "SELL" if p.qty > 0 else "BUY"
        o = ChildOrder(p.symbol, side, abs(p.qty), "MARKET", None, True, False,
                       client_id("flatten", p.symbol, "mkt"), "IOC")
        ex.place(o)
        typer.echo(f"flattened {p.symbol} {side} {abs(p.qty)}")
```

- [ ] **Step 3: Smoke (no network — import only)**

Run: `python -c "import fxhnt.cli" && fxhnt --help | grep -E "crypto-rebalance|crypto-flatten|crypto-positions"`
Expected: all three commands listed; import does not require network (ccxt client only constructed when a command runs).

- [ ] **Step 4: Full suite + lint + commit**

Run: `pytest -q` (all green) and `ruff check src/fxhnt/domain/execution src/fxhnt/adapters/exchange src/fxhnt/application/crypto_execution.py` (clean).

```bash
git add src/fxhnt/cli.py
git commit -m "feat(exec): CLI crypto-rebalance/positions/flatten"
```

---

# Milestone 5 — Testnet validation (runbook)

### Task 13: End-to-end on Binance futures testnet

> Real testnet fills (fake money). Requires Binance **futures testnet** API keys from
> https://testnet.binancefuture.com → set `FXHNT_BINANCE_API_KEY` / `FXHNT_BINANCE_API_SECRET`,
> `FXHNT_BINANCE_TESTNET=true`. This task SPENDS no real money but hits the network — run interactively.

- [ ] **Step 1: Configure testnet creds (local, never commit)**

Run (in your shell, not committed):
```bash
export FXHNT_BINANCE_API_KEY=<testnet key>
export FXHNT_BINANCE_API_SECRET=<testnet secret>
export FXHNT_BINANCE_TESTNET=true
```

- [ ] **Step 2: Verify connectivity + account**

Run: `fxhnt crypto-positions`
Expected: prints `NLV $… avail $… positions={}` (testnet account funded with test USDT). If auth fails, re-check keys + that they are **futures** testnet keys.

- [ ] **Step 3: Dry-run the rebalance**

Run: `fxhnt crypto-rebalance --data-dir /path/to/data --dry-run`
Expected: `[reb…] DRY-RUN orders=N halted=False reason=None drift={}` with N>0 and NO orders on the exchange.

- [ ] **Step 4: Execute on testnet (small)**

Temporarily set a tiny book: `export FXHNT_BINANCE_PER_SYMBOL_CAP=0.02` then
Run: `fxhnt crypto-rebalance --data-dir /path/to/data --execute`
Expected: `EXECUTED orders=N halted=False drift={}` (reconciliation clean). Check `fxhnt crypto-positions` shows the new positions.

- [ ] **Step 5: Verify TCA persisted**

Run: `python -c "from fxhnt.adapters.persistence.exec_store import ExecStore; from fxhnt.config import get_settings; import itertools; st=ExecStore(get_settings().operational_dsn); print([(f.symbol,f.status,round(f.shortfall_bps,1)) for f in st.fills(sorted({f.rebalance_id for f in []}) and 'reb...' or 'reb...')][:5])"`
(Or simpler: query the `exec_fills` table for the latest `rebalance_id`.) Expected: fills with realistic `shortfall_bps` (single-digit to low-double-digit bps on liquid symbols).

- [ ] **Step 6: Kill-switch test**

Run: `fxhnt crypto-flatten`
Expected: all testnet positions closed (reduce-only market). `fxhnt crypto-positions` → `positions={}`.

- [ ] **Step 7: Commit any fixes found during testnet validation**

```bash
git add -A && git commit -m "fix(exec): testnet validation fixes"
```

---

## Self-Review notes

- **Spec coverage:** crypto_exchange port (T2) · ccxt adapter + rate-limit governor + idempotency + testnet
  (T1,T5) · filters (T3) · smart router maker→IOC + caps + slippage abort (T6, IOC loop in T11) · capacity
  (T7) · risk gates + edge-decay×Kelly + kill-switch + drawdown CB (T8) · TCA (T9) · reconciliation (T10) ·
  orchestrator + cockpit persistence (T11) · CLI dry-run/execute/flatten (T12) · testnet e2e (T13). All
  spec sections map to a task.
- **Type consistency:** DTOs (`SymbolFilter`,`OrderBook`,`PerpPosition`,`CryptoAccount`,`ChildOrder`,`Fill`)
  defined in T2 and used unchanged in T3/T5/T6/T11. `RouteInput`/`PlannedOrder` (T6), `GateConfig`/`gross_scaler`
  (T8), `ExecConfig`/`RebalancePlan` (T11) consistent across the orchestrator + CLI. `client_id`/`parse_filter`/
  `RateBudget` (T5) reused in T11.
- **Honest scope note:** intraday time-slicing (TWAP/POV over minutes) is deliberately deferred — v1 caps
  order size by participation×ADV and depth and carries any excess to the next daily rebalance. After testnet
  TCA shows realized slippage, add slicing only if the data justifies it (measure-then-optimize). The CLI's
  ADV map is a placeholder (1e12 → capacity falls back to per_symbol_cap) until a real ADV feed is wired in
  M5 calibration — flagged, not hidden.
- **No placeholders** in code/tests; thresholds are config, not stubs.
