# Alpaca execution leg (fractional US-equity venue) — Design

**Status:** draft for review
**Date:** 2026-07-07

## Goal

Build the **Alpaca execution FOUNDATION** — a broker adapter on the clean `Broker` port + fractional
order sizing + a paper-first deploy — so any book that runs through the fxhnt `ExecutionService` can
trade on a **commission-free, fractional-share** US-equity venue. It runs immediately on the book
`ExecutionService` already rebalances (the survivor/research book via `fxhnt execute`), proving the
leg end-to-end. Fractional shares let a book implement its target weights **precisely** at small
capital, where IBKR's whole-share rounding distorts them. Paper-first (Alpaca paper account); live
is gated behind the existing `allow_live`.

**Scope note (from investigation):** the validated multi-strat ETF book's POSITION logic is NOT on
the clean port today — `domain/strategies/multistrat.py` exposes only a return series (`book_series`),
and its live IBKR execution is an archived, out-of-repo foxhunt script (`multistrat_bot.py` in a
configmap). Putting the multi-strat book itself on Alpaca additionally requires exposing its
per-instrument target weights, bridging them into `ExecutionService`, and handling its BTC-USD leg
(Alpaca equities can't trade BTC). That is a deliberate FOLLOW-UP (see Non-goals); this spec delivers
the shared foundation the follow-up will reuse.

## Why (fit + what changes)

The Broker port (`ports/broker.py`) is exactly two methods — `account_state()` + `place_order()` —
and `Order.quantity` is already a `float`, so the port ALREADY supports fractional; only the
adapters and the execution sizing round to whole shares. Two whole-share assumptions exist today:
- `adapters/broker/ibkr.py:54` — `int(round(order.quantity))` (IBKR rejects fractional, error 10243);
- `application/execution.py:90` — `qty = int(round(abs(delta)))` (the app-layer sizing).

Alpaca removes both: fractional orders + precise weights. This is an EXECUTION-venue change, not a
new alpha signal — the multi-strat book's edge is unchanged; it just gets a cheaper, fractional home.

## Design

### 1. `AlpacaSettings` (config.py, mirrors `IbkrSettings`)

```python
class AlpacaSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="FXHNT_ALPACA_")
    api_key: str = ""
    api_secret: str = ""
    base_url: str = "https://paper-api.alpaca.markets"   # paper default; live = https://api.alpaca.markets
    timeout: int = 15
    min_order_notional: float = 1.0                       # Alpaca min $1/order; skip dust below this
```
Added to `Settings` as `alpaca: AlpacaSettings`. Credentials come from env (k8s secret), never code.

### 2. `adapters/broker/alpaca.py` — `AlpacaBroker` (httpx REST)

Implements the `Broker` port against the Alpaca REST API (httpx is already a dep; no heavy SDK).
Mirrors the compact IBKR adapter.

- `supports_fractional = True` (a capability flag the execution layer reads; `IbkrBroker` gets
  `supports_fractional = False`).
- Auth via headers `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY`; one reused `httpx.Client`.
- `account_state()` → `GET /v2/account` (`portfolio_value`→nlv, `cash`) + `GET /v2/positions`
  (`{symbol: float(qty)}`, and `Σ|market_value|` → `gross_position_value` for the existing
  data-consistency sanity check). `is_paper = "paper-api" in base_url`.
- `place_order(order)` → `POST /v2/orders` `{symbol, qty: str(quantity), side: lower, type: "market",
  time_in_force: "day"}` (fractional requires market + day). Returns the response `status`.
- Raises on non-2xx with a trimmed message; `close()` / context-manager like `IbkrBroker`.

### 3. `execution.py` — fractional-aware sizing

Replace the hard whole-share round with a capability branch (whole-share path UNCHANGED for IBKR):

```python
frac = getattr(self._broker, "supports_fractional", False)
...
if frac:
    qty = round(abs(delta), 6)                      # fractional shares
    if qty * px < self._s.alpaca.min_order_notional: # skip sub-$1 dust
        continue
else:
    qty = int(round(abs(delta)))                    # whole shares (IBKR)
    if qty == 0:
        continue
```
The entry-floor / hysteresis band logic above it is unchanged (it already works in notional terms).
`PlannedOrder.quantity` stays a float; the whole-share path just happens to emit integers.

### 4. Wiring — CLI + deploy (paper-first, gated)

- **CLI:** add `--broker {ibkr,alpaca}` to `execute` (default `ibkr`). `alpaca` builds `AlpacaBroker`
  from settings and runs the SAME `ExecutionService(...).rebalance(book, execute=...)`. The existing
  `--live`/`allow_live` gate and dry-run default carry over unchanged (paper is the default).
- **Deploy:** a k8s CronJob `fxhnt-alpaca-rebalancer` mirroring the IBKR multistrat rebalancer —
  scheduled daily, `--broker alpaca` (paper base_url), reading `alpaca-credentials` (a k8s secret
  with `api_key`/`api_secret`). **Ship SUSPENDED** until the secret is populated (mirrors the Bybit
  testnet leg awaiting its key). A network policy allowing egress to `*.alpaca.markets:443`.

## Validation gates

1. **Adapter unit tests (mocked httpx, NO network):** `account_state()` parses
   portfolio_value/cash/positions/market_value; `place_order()` POSTs a FRACTIONAL qty payload
   (e.g. `qty="1.2345"`, `type=market`, `tif=day`) and returns the status; `is_paper` true on the
   paper base_url, false on live; a non-2xx raises. `supports_fractional is True`.
2. **Execution fractional path:** with a fake `supports_fractional=True` broker, a target implying
   1.2345 shares emits a fractional order (NOT rounded to 1); a sub-$1 delta is skipped; the
   whole-share path (fractional=False) is byte-unchanged (regression on the existing IBKR tests).
3. **Live-account guard still bites:** a non-paper Alpaca account with `allow_live=False` is BLOCKED
   (reuses the ExecutionService gate — no code path bypasses it).
4. Full suite green.

## Non-goals (explicit follow-ups)

- **The multi-strat ETF book on Alpaca — BUILT (follow-up completed same session).** `multistrat.target_weights`
  exposes the final-day `L·tw·scale` weights (byte-identical `book_series` via a shared `_volnorm_scale`);
  `ExecutionService.rebalance_weights` bridges pre-computed weights + caller prices through the same gates +
  fractional sizing; BTC-USD routes to **Alpaca crypto** ("BTC/USD", time_in_force gtc) via the crypto-aware
  `AlpacaBroker.place_order` (equities stay day). CLI `fxhnt execute-multistrat`; the CronJob runs it. The
  Alpaca-crypto position-symbol normalisation is validated on first real (paper) run.
- **No new alpha / signal change.** Purely a cheaper, fractional execution venue.
- **No live trading now.** Paper-first; `allow_live` stays the gate. The CronJob ships suspended.
- **No Alpaca crypto / options / streaming.** Only the two REST endpoints the Broker port needs.
- No migration OFF IBKR — the IBKR leg stays; Alpaca is added alongside (the fund can run both).

## Risks

- **Credentials required to run.** The leg is code-complete + tested but idle until Alpaca paper API
  keys are provided (like the Bybit testnet leg). The CronJob is suspended until then.
- **Alpaca fractional constraints:** fractional orders must be market + day and are US-market-hours
  only; the adapter fixes `type=market, tif=day` so a fractional order is never rejected for tif.
- **Weekend/holiday runs:** a rebalance outside market hours queues/rejects; the CronJob schedule
  should target a weekday market-open slot (documented in the manifest), and a rejected order is
  logged per-order (the existing `place_order` try/except in the rebalance loop) — never aborts the
  batch.
