Merge: multistrat self-cleans superseded lines (IDTM->CBU0)
This commit is contained in:
@@ -124,8 +124,11 @@ class ExecutionService:
|
||||
target_shares = nlv * weight / px
|
||||
current = acct.positions.get(sym, 0.0)
|
||||
delta = target_shares - current
|
||||
# A full EXIT (target weight 0 on a held line — e.g. closing a superseded UCITS line) ALWAYS
|
||||
# trades: hysteresis exists to damp churn, not to strand a position we're deliberately closing.
|
||||
is_exit = weight == 0.0 and current != 0.0
|
||||
threshold = (ex.entry_floor if current == 0 else ex.hysteresis) * floor_base
|
||||
if abs(delta * px) <= threshold:
|
||||
if not is_exit and abs(delta * px) <= threshold:
|
||||
continue
|
||||
if frac:
|
||||
qty: float = round(abs(delta), 6) # fractional shares
|
||||
|
||||
@@ -14,6 +14,20 @@ from typing import Any
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def superseded_holdings(*, fills: list[Any], current_targets: set[str],
|
||||
held: dict[str, float]) -> dict[str, float]:
|
||||
"""multistrat's OWN superseded lines to close: symbols it PREVIOUSLY traded (per its own `fills`) that are
|
||||
STILL held but are NO LONGER in the current target set — e.g. IDTM after the IEF->CBU0 remap. Keyed to the
|
||||
LAST recorded fill price (a real mark for the liquidating sell). SCOPED to multistrat's own fills, so a
|
||||
position another strategy holds on the SHARED IBKR account is NEVER included (a symbol multistrat never
|
||||
traded can't be here). Zero-qty holdings are ignored. Empty dict when the account is already clean."""
|
||||
last_price: dict[str, float] = {}
|
||||
for f in sorted(fills, key=lambda f: f.at): # ascending -> the LAST fill's price wins
|
||||
last_price[f.symbol] = float(f.price)
|
||||
return {sym: last_price[sym] for sym, qty in held.items()
|
||||
if qty and sym in last_price and sym not in current_targets}
|
||||
|
||||
|
||||
def scaled_weights(within_weights: dict[str, float], envelope: float, nlv: float) -> dict[str, float]:
|
||||
if envelope <= 0.0 or nlv <= 0.0:
|
||||
return {s: 0.0 for s in within_weights} # flatten to cash (B2a $0 / no NLV)
|
||||
@@ -108,8 +122,30 @@ def plan_and_record(*, repo: Any, exec_svc: Any, within_weights: dict[str, float
|
||||
else:
|
||||
envelope = min(float(repo.current_allocation().get("multistrat", 0.0)), float(nlv))
|
||||
weights = scaled_weights(within_weights, envelope, nlv)
|
||||
# SELF-CLEANING: close multistrat's OWN superseded lines (held + previously traded by multistrat + no
|
||||
# longer a target — e.g. IDTM after the IEF->CBU0 remap) so a mapping change doesn't strand legacy holdings
|
||||
# in the account NLV. Scoped to multistrat's own fills (NEVER another strategy's position on the SHARED
|
||||
# account) and best-effort (a scan failure must never block the rebalance). Added at target weight 0, so
|
||||
# `_plan` closes the FULL holding (a weight-0 exit bypasses hysteresis). Priced at the last fill; UCITS
|
||||
# orphans resolve on LSEETF/USD by symbol (no ISIN needed to SELL a line already held).
|
||||
prices = dict(prices)
|
||||
specs = dict(contract_specs or {})
|
||||
if execute and broker is not None and account_repo is not None and account_is_paper:
|
||||
try:
|
||||
held = broker.account_state().positions
|
||||
fills = account_repo.fills_for("multistrat", limit=10000)
|
||||
superseded = superseded_holdings(fills=fills, current_targets=set(weights), held=held)
|
||||
for sym, px in superseded.items():
|
||||
weights[sym] = 0.0
|
||||
prices.setdefault(sym, px)
|
||||
if "ucits" in str(venue).lower():
|
||||
specs.setdefault(sym, {"exchange": "LSEETF", "currency": "USD"})
|
||||
if superseded:
|
||||
log.info("multistrat self-clean: closing superseded line(s) %s", sorted(superseded))
|
||||
except Exception: # noqa: BLE001 — the cleanup scan is best-effort; never block a real rebalance over it
|
||||
log.warning("multistrat superseded-line scan failed (skipped)", exc_info=True)
|
||||
plan = exec_svc.rebalance_weights("multistrat", weights, prices, max_gross=max_gross, execute=execute,
|
||||
contract_specs=contract_specs, sizing_capital=envelope)
|
||||
contract_specs=specs, sizing_capital=envelope)
|
||||
if execute and not plan.blocked:
|
||||
pos_prior = repo.get_book_state("multistrat_exec_pos")
|
||||
exec_return = exec_return_for_run(pos_prior, prices, venue)
|
||||
|
||||
@@ -166,3 +166,26 @@ def test_rebalance_weights_ucits_path_enriches_order_with_contract_spec() -> Non
|
||||
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
|
||||
|
||||
|
||||
def test_weight_zero_exit_always_closes_a_held_line() -> None:
|
||||
# A held line taken to weight 0 (a superseded UCITS line being closed) ALWAYS sells — even a tiny holding
|
||||
# that hysteresis would otherwise strand. This is what makes the self-cleaning of legacy lines (IDTM after
|
||||
# the IEF->CBU0 remap) actually liquidate instead of lingering.
|
||||
broker = FakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={"IDTM": 1.0}, is_paper=True))
|
||||
svc = ExecutionService(FakeData(), broker, _settings())
|
||||
plan = svc.rebalance_weights("multistrat", {"IDTM": 0.0}, {"IDTM": 96.0}, execute=True,
|
||||
sizing_capital=1_000_000)
|
||||
assert not plan.blocked
|
||||
sells = [o for o in broker.placed if o.symbol == "IDTM" and o.side == "SELL"]
|
||||
assert len(sells) == 1 and sells[0].quantity == 1
|
||||
|
||||
|
||||
def test_held_line_at_tiny_nonzero_weight_is_left_in_band() -> None:
|
||||
# Contrast: the SAME tiny holding at a small NONZERO target weight is within hysteresis -> no order (only a
|
||||
# deliberate weight-0 EXIT bypasses the band, not every small drift).
|
||||
broker = FakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={"IDTM": 1.0}, is_paper=True))
|
||||
svc = ExecutionService(FakeData(), broker, _settings())
|
||||
plan = svc.rebalance_weights("multistrat", {"IDTM": 0.0000001}, {"IDTM": 96.0}, execute=True,
|
||||
sizing_capital=1_000_000)
|
||||
assert not broker.placed
|
||||
|
||||
@@ -144,3 +144,35 @@ def test_plan_and_record_default_mode_reads_b2a():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user