Files
fxhnt/tests/integration/test_execution.py
jgrusewski fd9534e978 feat(exec): multistrat book on Alpaca — target-weight bridge + crypto BTC leg
Puts the adaptive multi-strat ETF book on the clean execution port:
- multistrat.target_weights(closes): exposes the final-day per-instrument weights
  (L*tw*volnorm_scale) that book_series applies but only collapses into a return.
  Refactored _volnorm via a shared _volnorm_scale so book_series stays byte-identical.
  Tested: sum(w*R[last]) reconstructs book_series's last booked return exactly.
- ExecutionService.rebalance_weights(weights, prices): a direct pre-computed-weights
  entry beside rebalance(book), sharing one _plan engine (gates + fractional sizing).
  Caller owns data<->broker symbol mapping.
- AlpacaBroker crypto-aware: a slash symbol (BTC/USD) -> time_in_force gtc; equities
  stay day. So the book's BTC-USD leg trades on Alpaca crypto alongside the 5 ETFs.
- CLI fxhnt execute-multistrat (maps BTC-USD -> BTC/USD); the alpaca-rebalancer
  CronJob now runs it (suspended until paper keys).

Full suite green (1832); +5 tests. Alpaca-crypto position-symbol form validated on
first paper run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:33:22 +02:00

120 lines
5.2 KiB
Python

"""Execution engine end-to-end with a FAKE data provider + FAKE broker (no network, no IBKR).
Proves the multi-strategy rebalance: netting → reconcile vs broker → whole-share orders → gates."""
from __future__ import annotations
import numpy as np
from fxhnt.application import ExecutionService
from fxhnt.config import ExecutionSettings, Settings
from fxhnt.domain.models import AssetClass, Market, PriceSeries, StrategySpec
from fxhnt.domain.portfolio import Book, StrategyAllocation
from fxhnt.ports.broker import AccountState, Order
SPY = Market(symbol="SPY", asset_class=AssetClass.ETF)
class FakeData:
name = "fake"
def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
close = 100.0 * np.cumprod(1.0 + np.full(400, 0.001)) # uptrend -> trend long
return PriceSeries(market=market, dates=tuple(str(i) for i in range(400)), close=close)
class FakeBroker:
name = "fake"
def __init__(self, state: AccountState) -> None:
self.state = state
self.placed: list[Order] = []
def account_state(self) -> AccountState:
return self.state
def place_order(self, order: Order) -> str:
self.placed.append(order)
return "Filled"
def _book() -> Book:
return Book(name="b", max_gross_leverage=1.0, allocations=[
StrategyAllocation(spec=StrategySpec(kind="trend", params={"window": 50}), market=SPY, weight=1.0),
])
def _settings() -> Settings:
return Settings(execution=ExecutionSettings(allow_live=False))
def test_rebalance_from_flat_places_buy() -> None:
broker = FakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance(_book(), execute=True)
assert not plan.blocked
assert plan.executed and len(broker.placed) == 1
o = broker.placed[0]
assert o.symbol == "SPY" and o.side == "BUY"
# ~100% of 100k at price ~149 (100*1.001^399) -> ~670 shares
assert 600 < o.quantity < 750
def test_in_band_when_already_on_target_no_orders() -> None:
# already holding ~full target -> hysteresis says do nothing
broker = FakeBroker(AccountState(nlv=100_000, cash=0, positions={"SPY": 670}, is_paper=True))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance(_book(), execute=True)
assert not plan.executed and not broker.placed
assert any("in band" in n for n in plan.notes)
def test_live_account_blocked_without_allow_live() -> None:
broker = FakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=False))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance(_book(), execute=True)
assert plan.blocked and not broker.placed
def test_inconsistent_account_data_blocks() -> None:
# NLV says 100k but cash+positions says 60k -> 40% gap > data_tol -> refuse
broker = FakeBroker(AccountState(nlv=100_000, cash=10_000, positions={}, is_paper=True, gross_position_value=50_000))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance(_book(), execute=True)
assert plan.blocked and not broker.placed
class FractionalFakeBroker(FakeBroker):
supports_fractional = True # Alpaca-style: the execution layer must size fractional shares
def test_fractional_broker_places_precise_fractional_quantity() -> None:
# A broker that supports fractional shares gets the EXACT target quantity (not whole-share rounded),
# so the book implements its weights precisely — the whole point of the Alpaca leg.
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance(_book(), execute=True)
assert plan.executed and len(broker.placed) == 1
q = broker.placed[0].quantity
assert 600.0 < q < 750.0
assert q != int(q) # FRACTIONAL — has a decimal part, not the whole-share integer
assert q == round(q, 6) # sized to the fractional precision, not arbitrary float noise
def test_rebalance_weights_places_from_precomputed_targets() -> None:
# The multistrat bridge: rebalance to PRE-COMPUTED weights + caller-supplied prices (no book/data fetch),
# reusing the same gates + fractional sizing. 60/40 of 100k at $100 → 600 / 400 shares.
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance_weights("multistrat", {"SPY": 0.6, "IEF": 0.4},
{"SPY": 100.0, "IEF": 100.0}, max_gross=1.5, execute=True)
assert not plan.blocked and plan.executed
placed = {o.symbol: o.quantity for o in broker.placed}
assert placed == {"SPY": 600.0, "IEF": 400.0}
def test_rebalance_weights_blocks_when_gross_exceeds_max() -> None:
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance_weights("x", {"SPY": 1.2, "IEF": 0.5},
{"SPY": 100.0, "IEF": 100.0}, max_gross=1.5, execute=True)
assert plan.blocked and not broker.placed # gross 1.7 > 1.5 → refuse