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>
116 lines
4.8 KiB
Python
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
|