Files
fxhnt/tests/unit/test_vrp_exec_record.py
jgrusewski c4fb3f73eb fix(vrp): exec-twin ladder cap + per-rung sizing + combo BUY-credit sign + backfill master branch + P&L helper tests
The VRP exec twin previously opened a NEW full-envelope spread every run, overwriting the
single-slot book-state and never closing the prior one -- armed weekly it would stack N x
notional. plan_and_record_vrp now maintains a 5-rung ladder (mirrors VrpStrategy.advance):
closes rungs at 50% profit-take or DTE<=1 first, then opens at most one new rung sized at
envelope/ladder (never the full envelope) only when a slot is free. Ladder decisions are
factored into a pure, unit-tested plan_vrp_ladder helper.

Also: IbkrBroker.place_combo used the wrong parent-order action for a net-credit combo (SELL
with a negative limit); switched to BUY with a negative limit per IBKR's combo convention.
fxhnt-opra-backfill.yaml's git-sync cloned the feature branch instead of master (this Job is
applied manually post-merge). Added tests/unit/test_vrp_exec_helpers.py for the previously
untested pure P&L helpers (compute_spread_exec_return/compute_ladder_exec_return/build_pos_state).
Removed the dead --live flag from execute-vrp (plan_and_record_vrp never consulted allow_live;
the paper-envelope refusal guard is the real safety mechanism).

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

116 lines
4.8 KiB
Python

import pytest
from fxhnt.application.vrp_exec_record import (
build_close_legs,
build_combo_legs,
plan_vrp_ladder,
refuse_if_live,
rung_notional,
)
def test_build_combo_legs_is_defined_risk_credit():
legs, net = build_combo_legs(short_osi="XSP..P00470000", long_osi="XSP..P00465000",
short_price=1.20, long_price=0.40, qty=1)
assert ("XSP..P00470000", "SELL", 1) in legs
assert ("XSP..P00465000", "BUY", 1) in legs
assert net == pytest.approx(-0.80) # negative limit = net credit received
def test_build_close_legs_reverses_open_legs_with_positive_debit():
legs, net = build_close_legs(short_osi="XSP..P00470000", long_osi="XSP..P00465000", qty=1, mark_today=0.30)
assert ("XSP..P00470000", "BUY", 1) in legs # BUY back the short
assert ("XSP..P00465000", "SELL", 1) in legs # SELL the long
assert net == pytest.approx(0.30) # positive limit = debit paid to close
def _rung(*, short_osi="S1", long_osi="L1", credit=1.0, qty=2, expiry="2026-09-01"):
return {"entry": "2026-07-06", "expiry": expiry, "short_osi": short_osi, "long_osi": long_osi,
"credit": credit, "last_val": credit, "qty": qty}
def test_plan_vrp_ladder_no_stacking_when_ladder_is_full():
# 5 rungs already open, none hitting profit-take/DTE -> no closes -> ladder stays full -> may NOT open.
opens = [_rung(short_osi=f"S{i}") for i in range(5)]
marks = {sp["short_osi"]: 0.9 for sp in opens} # well above the 50% profit-take line, DTE far out
to_close, may_open, slots = plan_vrp_ladder(opens, marks, "2026-07-06", ladder=5, entry_weekday=0)
assert to_close == []
assert may_open is False
assert slots == 0
def test_plan_vrp_ladder_closes_rung_at_dte_le_1():
sp = _rung(expiry="2026-07-07") # DTE = 1 on "2026-07-06"
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.9}, "2026-07-06", ladder=5, entry_weekday=0)
assert len(to_close) == 1
assert to_close[0][0] is sp
def test_plan_vrp_ladder_closes_rung_at_profit_take():
sp = _rung(credit=1.0)
# mark <= (1 - profit_take) * credit == 0.50 -> closes
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.40}, "2026-07-06", ladder=5,
profit_take=0.5, entry_weekday=0)
assert len(to_close) == 1
def test_plan_vrp_ladder_holds_rung_with_no_mark_today():
sp = _rung()
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": None}, "2026-07-06", ladder=5, entry_weekday=0)
assert to_close == [] # no mark -> HOLD, not a close
def test_plan_vrp_ladder_may_open_on_entry_day_with_free_slot():
to_close, may_open, slots = plan_vrp_ladder([], {}, "2026-07-06", ladder=5, entry_weekday=0) # a Monday
assert to_close == []
assert may_open is True
assert slots == 1
def test_plan_vrp_ladder_no_open_off_entry_day():
to_close, may_open, slots = plan_vrp_ladder([], {}, "2026-07-07", ladder=5, entry_weekday=0) # a Tuesday
assert may_open is False
assert slots == 0
def test_plan_vrp_ladder_open_frees_up_when_a_close_makes_room():
# ladder full (5/5), but one rung closes this run -> a slot opens for a new entry.
opens = [_rung(short_osi=f"S{i}", expiry="2026-07-07") if i == 0 else _rung(short_osi=f"S{i}")
for i in range(5)]
marks = {sp["short_osi"]: 0.9 for sp in opens}
to_close, may_open, slots = plan_vrp_ladder(opens, marks, "2026-07-06", ladder=5, entry_weekday=0)
assert len(to_close) == 1
assert may_open is True
assert slots == 1
def test_rung_notional_splits_envelope_across_ladder_and_bounds_total():
per_rung = rung_notional(1_000_000.0, 5)
assert per_rung == pytest.approx(200_000.0)
assert per_rung * 5 == pytest.approx(1_000_000.0) # total open notional bounded at ~envelope
class _RaisingBroker:
"""A fake broker whose place_combo must NEVER be called by a refused (non-paper, real-capital) run."""
def __init__(self) -> None:
self.calls = 0
def place_combo(self, legs, net_limit): # noqa: ANN001 - test double
self.calls += 1
raise AssertionError("place_combo must not be called when the paper-envelope guard refuses")
def test_refuse_if_live_blocks_non_paper_account_and_places_zero_orders():
broker = _RaisingBroker()
with pytest.raises(ValueError, match="refused"):
refuse_if_live(paper_envelope=1_000_000, account_is_paper=False)
assert broker.calls == 0
def test_refuse_if_live_allows_paper_account():
refuse_if_live(paper_envelope=1_000_000, account_is_paper=True) # must not raise
def test_refuse_if_live_allows_zero_envelope_on_any_account():
refuse_if_live(paper_envelope=0.0, account_is_paper=False) # off = no real-capital exposure guard