Files
fxhnt/tests/unit/test_multistrat_exec_helpers.py
jgrusewski 38b8ff26d2 feat(exec): multistrat self-cleans its OWN superseded lines (fixes lingering IDTM)
The IEF->CBU0 UCITS remap (515117d, 2026-07-16 12:28) left the paper account
holding IDTM (the old thin Dist line, bought on the 07-15/16 09:00 runs under the
prior IEF->IDTM config). The rebalancer only ever traded its TARGET symbols
(_plan iterates targets.items()), so it never sold the orphaned IDTM — it lingered
in the account NLV and (correctly) tripped the stray-holdings warning.

Fix — scoped, safe self-cleaning:
- superseded_holdings(): the symbols multistrat ITSELF previously traded (per its
  own exec_fills) that are still held but no longer a target — priced at the last
  fill. Scoped to multistrat's own fills, so a position ANOTHER strategy holds on
  the SHARED IBKR account is never touched.
- plan_and_record: adds them at target weight 0 (+ LSEETF/USD spec so a UCITS line
  resolves by symbol to SELL) before the rebalance. Best-effort: a scan failure
  never blocks a real rebalance.
- _plan: a full EXIT (weight 0 on a held line) ALWAYS trades — hysteresis damps
  churn, it must not strand a position we're deliberately closing.

Next UCITS rebalance (09:00) closes IDTM and buys CBU0; the warning clears. Also
prevents future mapping-change legacy from silently accreting in the NLV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:30:00 +02:00

179 lines
8.4 KiB
Python

from fxhnt.application.multistrat_exec_record import build_pos_state, compute_exec_return, scaled_weights
def test_scaled_weights_bridge_and_flatten():
w = {"SPY": 0.6, "IEF": 0.4}
# envelope 10k in a 100k account → scale 0.1 → weights sum to envelope/NLV
s = scaled_weights(w, envelope=10_000.0, nlv=100_000.0)
assert abs(s["SPY"] - 0.06) < 1e-12 and abs(s["IEF"] - 0.04) < 1e-12
assert scaled_weights(w, 0.0, 100_000.0) == {"SPY": 0.0, "IEF": 0.0} # $0 envelope → flatten
assert scaled_weights(w, 10_000.0, 0.0) == {"SPY": 0.0, "IEF": 0.0} # no NLV → flatten (safe)
def test_compute_exec_return_first_run_is_none():
assert compute_exec_return({}, {"SPY": 500.0}) is None # no prior book → inception
def test_exec_return_is_envelope_basis_not_full_nlv():
# prior book: $10k envelope fully deployed in 20 SPY @ 500 (=$10k), in a (hypothetical) large account.
prior = build_pos_state({"SPY": 20.0}, {"SPY": 500.0}, envelope=10_000.0)
# SPY +10% today → book +$1000 on the $10k envelope → 10% return (NOT diluted by any idle account cash)
assert abs(compute_exec_return(prior, {"SPY": 550.0}) - 0.10) < 1e-9
def test_reallocation_capital_flow_is_not_return():
# flat prices; the envelope will grow at the NEXT rebalance (a flow) — the RETURN of the prior book is ~0
prior = build_pos_state({"SPY": 20.0}, {"SPY": 500.0}, envelope=10_000.0)
assert abs(compute_exec_return(prior, {"SPY": 500.0})) < 1e-12 # flat prices → 0%, not a jump
def test_partial_deployment_reserve_earns_zero():
# $10k envelope, only $6k deployed (12 SPY @ 500), $4k reserve. SPY +10% → +$600 on $10k = 6%.
prior = build_pos_state({"SPY": 12.0}, {"SPY": 500.0}, envelope=10_000.0)
assert abs(compute_exec_return(prior, {"SPY": 550.0}) - 0.06) < 1e-9
def test_none_prior_returns_none():
# None prior → inception, no AttributeError on .get() call
assert compute_exec_return(None, {"SPY": 500.0}) is None
def test_missing_price_degrades_not_raises():
# Position absent from today's prices uses prior price; no KeyError raised
prior = build_pos_state({"SPY": 20.0}, {"SPY": 500.0}, envelope=10_000.0)
assert compute_exec_return(prior, {}) == 0.0
def test_build_pos_state_persists_venue():
from fxhnt.application.multistrat_exec_record import build_pos_state
st = build_pos_state({"SPY": 10.0}, {"SPY": 5.0}, 1000.0, venue="ibkr-equity")
assert st["venue"] == "ibkr-equity" and st["envelope"] == 1000.0
def test_build_pos_state_venue_defaults_empty():
from fxhnt.application.multistrat_exec_record import build_pos_state
assert build_pos_state({}, {}, 0.0)["venue"] == ""
def test_exec_return_venue_switch_is_inception():
"""F3: the FIRST run on a new venue (prior book keyed by the OTHER venue's symbols) books NO return —
comparing US SPY-keyed positions against UCITS CSPX-keyed prices would be garbage."""
from fxhnt.application.multistrat_exec_record import exec_return_for_run
us_prior = build_pos_state({"SPY": 20.0}, {"SPY": 500.0}, envelope=10_000.0, venue="ibkr-paper")
# switching to the UCITS venue with UCITS-keyed prices -> inception (None), NOT a spurious return
assert exec_return_for_run(us_prior, {"CSPX": 620.0}, venue="ibkr-ucits") is None
def test_exec_return_same_venue_books_normal_return():
"""After the inception run, subsequent SAME-venue runs book the real return as usual."""
from fxhnt.application.multistrat_exec_record import exec_return_for_run
ucits_prior = build_pos_state({"CSPX": 16.0}, {"CSPX": 620.0}, envelope=10_000.0, venue="ibkr-ucits")
# $10k envelope, ~$9,920 deployed (16 CSPX @ 620); CSPX +10% -> +$992 on $10k ≈ 9.92%
r = exec_return_for_run(ucits_prior, {"CSPX": 682.0}, venue="ibkr-ucits")
assert r is not None and abs(r - 0.0992) < 1e-4
def test_exec_return_no_prior_is_inception():
from fxhnt.application.multistrat_exec_record import exec_return_for_run
assert exec_return_for_run({}, {"CSPX": 620.0}, venue="ibkr-ucits") is None
assert exec_return_for_run(None, {"CSPX": 620.0}, venue="ibkr-ucits") is None
class _FakeExecSvc:
def __init__(self):
self.calls = []
def rebalance_weights(self, sid, weights, prices, *, max_gross, execute, contract_specs=None,
sizing_capital=None):
self.calls.append((sid, dict(weights), execute))
self.sizing_capital = sizing_capital # the envelope basis for the entry-floor threshold
class _Plan:
nlv = 100000.0; orders = []; notes = []; blocked = False; executed = execute
return _Plan()
class _FakeRepo:
def __init__(self, alloc):
self._alloc = alloc; self.book = {}
def current_allocation(self): # B2a — must NOT be called in paper mode
raise AssertionError("current_allocation must not be read in paper mode")
def get_book_state(self, k):
return self.book.get(k, {})
def set_book_state(self, k, extra, at):
self.book[k] = extra
class _B2aRepo(_FakeRepo):
def current_allocation(self):
return self._alloc
def test_plan_and_record_paper_mode_bypasses_b2a():
import datetime as dt
from fxhnt.application.multistrat_exec_record import plan_and_record
repo = _FakeRepo(alloc={"multistrat": 0.0}); svc = _FakeExecSvc()
_plan, envelope = plan_and_record(
repo=repo, exec_svc=svc, within_weights={"SPY": 1.0}, prices={"SPY": 10.0},
nlv=500000.0, today="2026-07-11", at=dt.datetime(2026, 7, 11), max_gross=2.0,
execute=False, paper_envelope=1_000_000.0, account_is_paper=True, venue="ibkr-equity")
assert envelope == 500000.0 # min(1e6 paper, 5e5 nlv); B2a NOT consulted
def test_plan_and_record_paper_mode_refuses_live_account():
import datetime as dt
import pytest
from fxhnt.application.multistrat_exec_record import plan_and_record
repo = _FakeRepo(alloc={"multistrat": 0.0}); svc = _FakeExecSvc()
with pytest.raises(ValueError, match="paper-envelope refused"):
plan_and_record(
repo=repo, exec_svc=svc, within_weights={"SPY": 1.0}, prices={"SPY": 10.0},
nlv=500000.0, today="2026-07-11", at=dt.datetime(2026, 7, 11), max_gross=2.0,
execute=True, paper_envelope=1_000_000.0, account_is_paper=False, venue="ibkr-equity")
assert svc.calls == [] # NO orders on refusal
def test_plan_and_record_default_mode_reads_b2a():
import datetime as dt
from fxhnt.application.multistrat_exec_record import plan_and_record
repo = _B2aRepo(alloc={"multistrat": 25000.0}); svc = _FakeExecSvc()
_plan, envelope = plan_and_record(
repo=repo, exec_svc=svc, within_weights={"SPY": 1.0}, prices={"SPY": 10.0},
nlv=500000.0, today="2026-07-11", at=dt.datetime(2026, 7, 11), max_gross=2.0,
execute=False, paper_envelope=0.0, account_is_paper=True)
assert envelope == 25000.0 # min($25k B2a, $500k nlv)
# ---- superseded_holdings: self-cleaning of multistrat's OWN legacy lines (e.g. IDTM after IEF->CBU0) --------
import datetime as _dt
from types import SimpleNamespace
from fxhnt.application.multistrat_exec_record import superseded_holdings
def _fill(sym, price, day):
return SimpleNamespace(symbol=sym, price=price, at=_dt.datetime(2026, 7, day, 9, 0))
def test_superseded_flags_own_legacy_line_at_last_fill_price():
# multistrat traded IDTM (the legacy IEF line) + CSPX; it now targets CSPX/CBU0/IGLN. Held: IDTM + CSPX.
fills = [_fill("IDTM", 95.0, 15), _fill("CSPX", 500.0, 15), _fill("IDTM", 96.0, 16)]
out = superseded_holdings(fills=fills, current_targets={"CSPX", "CBU0", "IGLN"},
held={"IDTM": 98.0, "CSPX": 24.0})
assert out == {"IDTM": 96.0} # IDTM: held + traded + not a target -> flagged @ LAST fill price
def test_superseded_never_touches_another_strategys_position():
# A held symbol multistrat NEVER traded (another strategy's line on the SHARED account) is never flagged.
out = superseded_holdings(fills=[_fill("CSPX", 500.0, 15)], current_targets={"CSPX"},
held={"CSPX": 24.0, "SOMEOTHER": 10.0})
assert out == {}
def test_superseded_ignores_zero_qty_and_current_targets():
out = superseded_holdings(fills=[_fill("IDTM", 95.0, 15), _fill("CSPX", 500.0, 15)],
current_targets={"CSPX", "CBU0"}, held={"IDTM": 0.0, "CSPX": 24.0})
assert out == {} # IDTM flat (0 qty); CSPX is a current target