The IGLN gold-sleeve 'priced but no order' bug. scaled_weights sizes orders to the paper envelope (envelope*within_weight), but _plan's entry-floor/hysteresis threshold used entry_floor*nlv (the full $1M account NLV) — so the effective floor was ~entry_floor*(nlv/envelope) of the envelope (~5%), silently dropping every sleeve below it. A 3% gold sleeve at a $100k envelope on a $1M account skipped. FIX: rebalance_weights takes sizing_capital (the envelope); the floor is entry_floor*min(sizing_capital,nlv). Sizing + recording unchanged. Regression test: a 3% sleeve survives WITH the envelope basis, drops without it. 2005 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
8.3 KiB
Python
169 lines
8.3 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_entry_floor_uses_sizing_capital_not_full_nlv() -> None:
|
|
# Regression (the IGLN gold-sleeve drop): the book is sized to a $100k envelope on a $1M paper account, so
|
|
# weights come pre-scaled by envelope/nlv (=0.1). A 3% sleeve = $3k of the envelope. entry_floor is 0.5%:
|
|
# on the envelope that's $500 (place), but on the raw $1M NLV it's $5k (would silently drop the sleeve).
|
|
scaled = {"SPY": 0.097 * 0.1, "GLD": 0.030 * 0.1} # envelope-scaled (scale = 100k/1M)
|
|
prices = {"SPY": 800.0, "GLD": 80.0} # GLD order ≈ $3,000 (between $500 and $5,000)
|
|
# WITH sizing_capital (the fix): the 3% sleeve survives the envelope-based floor.
|
|
b1 = FractionalFakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={}, is_paper=True))
|
|
ExecutionService(FakeData(), b1, _settings()).rebalance_weights(
|
|
"multistrat", scaled, prices, max_gross=1.5, execute=True, sizing_capital=100_000)
|
|
assert "GLD" in {o.symbol for o in b1.placed}
|
|
# WITHOUT it: the same sleeve is dropped by the NLV-based floor — the bug the fix addresses.
|
|
b2 = FractionalFakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={}, is_paper=True))
|
|
ExecutionService(FakeData(), b2, _settings()).rebalance_weights(
|
|
"multistrat", scaled, prices, max_gross=1.5, execute=True)
|
|
assert "GLD" not in {o.symbol for o in b2.placed}
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_rebalance_weights_us_path_order_uses_default_contract_fields() -> None:
|
|
"""--venue us (no contract_specs): the placed Order carries the SMART/USD defaults and NO secId —
|
|
the US path is byte-unchanged."""
|
|
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}, {"SPY": 100.0},
|
|
max_gross=1.5, execute=True)
|
|
assert plan.executed
|
|
o = broker.placed[0]
|
|
assert o.exchange == "SMART" and o.currency == "USD"
|
|
assert o.sec_id_type is None and o.sec_id is None
|
|
|
|
|
|
def test_rebalance_weights_ucits_path_enriches_order_with_contract_spec() -> None:
|
|
"""--venue ucits: contract_specs enrich the placed Order with the resolving ISIN/exchange/currency,
|
|
and sizing uses the caller-supplied (REAL UCITS) price."""
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
specs = {"CSPX": {"exchange": "SMART", "currency": "USD",
|
|
"sec_id_type": "ISIN", "sec_id": "IE00B5BMR087"}}
|
|
plan = svc.rebalance_weights("multistrat", {"CSPX": 0.6}, {"CSPX": 620.0},
|
|
max_gross=1.5, execute=True, contract_specs=specs)
|
|
assert plan.executed
|
|
o = broker.placed[0]
|
|
assert o.symbol == "CSPX"
|
|
assert o.sec_id_type == "ISIN" and o.sec_id == "IE00B5BMR087"
|
|
assert o.currency == "USD"
|
|
# sized from the REAL UCITS price 620 (60% of 100k / 620 ≈ 96.77), NOT a US price
|
|
assert abs(o.quantity - (0.6 * 100_000 / 620.0)) < 1e-6
|