Layer-by-layer: domain/portfolio (Book + StrategyAllocation + compute_target_weights — nets many sleeves, incl. same market, into per-market targets capped at gross leverage) | ports/broker (Broker contract + Order/AccountState DTOs) | application/execution (ExecutionService: net → reconcile vs broker → whole-share orders with entry-floor/hysteresis, behind the hard-won gates: paper-guard, data-consistency/conservative-NLV, leverage-cap) | config ExecutionSettings. Tested with fake data+broker (no network/IBKR): netting, leverage cap, buy-from-flat, in-band no-op, live-block, inconsistent-data-block. 10/10 tests green. IBKR adapter (behind Broker port) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
3.1 KiB
Python
83 lines
3.1 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
|