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>
120 lines
5.2 KiB
Python
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
|