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>
81 lines
3.6 KiB
Python
81 lines
3.6 KiB
Python
import pytest
|
|
|
|
from fxhnt.application.vrp_exec_record import (
|
|
build_pos_state,
|
|
compute_ladder_exec_return,
|
|
compute_spread_exec_return,
|
|
)
|
|
|
|
|
|
def test_compute_spread_exec_return_envelope_zero_is_none():
|
|
assert compute_spread_exec_return(prior_val=1.0, mark_today=0.8, qty=2, envelope=0.0) is None
|
|
|
|
|
|
def test_compute_spread_exec_return_no_mark_today_is_hold_not_phantom_zero():
|
|
assert compute_spread_exec_return(prior_val=1.0, mark_today=None, qty=2, envelope=200_000.0) is None
|
|
|
|
|
|
def test_compute_spread_exec_return_winning_spread_is_positive_credit_decay():
|
|
# short-vol: credit decayed from 1.00 -> 0.60 = profit. qty=2, $100/contract multiplier.
|
|
r = compute_spread_exec_return(prior_val=1.00, mark_today=0.60, qty=2, envelope=200_000.0)
|
|
assert r == pytest.approx((1.00 - 0.60) * 100.0 * 2 / 200_000.0)
|
|
assert r > 0.0
|
|
|
|
|
|
def test_compute_spread_exec_return_losing_spread_is_negative_and_bounded_by_width():
|
|
# a defined-risk put-credit-spread's worst mark is bounded by the wing width ($5 here = $500/contract);
|
|
# the value rising toward that cap is a loss, correctly signed negative.
|
|
r = compute_spread_exec_return(prior_val=1.00, mark_today=4.50, qty=2, envelope=200_000.0)
|
|
assert r == pytest.approx((1.00 - 4.50) * 100.0 * 2 / 200_000.0)
|
|
assert r < 0.0
|
|
|
|
|
|
def test_compute_ladder_exec_return_inception_no_prior_rungs_is_none():
|
|
assert compute_ladder_exec_return([], {}, 200_000.0) is None
|
|
|
|
|
|
def test_compute_ladder_exec_return_definanced_envelope_is_none():
|
|
prior = [{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2}]
|
|
assert compute_ladder_exec_return(prior, {"S1": 0.5}, 0.0) is None
|
|
|
|
|
|
def test_compute_ladder_exec_return_sums_only_marked_rungs():
|
|
prior = [
|
|
{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2},
|
|
{"short_osi": "S2", "long_osi": "L2", "credit": 1.0, "last_val": 1.0, "qty": 2},
|
|
]
|
|
marks = {"S1": 0.60, "S2": None} # S2 has no mark today -> HOLD, contributes 0 (not a phantom zero)
|
|
r = compute_ladder_exec_return(prior, marks, 200_000.0)
|
|
assert r == pytest.approx((1.00 - 0.60) * 100.0 * 2 / 200_000.0) # only S1's marked pnl
|
|
|
|
|
|
def test_compute_ladder_exec_return_all_holds_is_none():
|
|
prior = [{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2}]
|
|
assert compute_ladder_exec_return(prior, {"S1": None}, 200_000.0) is None
|
|
|
|
|
|
def test_build_pos_state_round_trip():
|
|
opens = [{"entry": "2026-07-06", "expiry": "2026-08-15", "short_osi": "S1", "long_osi": "L1",
|
|
"credit": 1.0, "last_val": 0.6, "qty": 2}]
|
|
st = build_pos_state(opens, envelope=200_000.0, venue="ibkr-paper", today="2026-07-13")
|
|
assert st["open_spreads"] == opens
|
|
assert st["envelope"] == 200_000.0
|
|
assert st["venue"] == "ibkr-paper"
|
|
assert st["last_run_date"] == "2026-07-13"
|
|
|
|
|
|
def test_build_pos_state_defensively_copies_rungs():
|
|
opens = [{"entry": "2026-07-06", "expiry": "2026-08-15", "short_osi": "S1", "long_osi": "L1",
|
|
"credit": 1.0, "last_val": 0.6, "qty": 2}]
|
|
st = build_pos_state(opens, envelope=200_000.0, venue="ibkr-paper", today="2026-07-13")
|
|
opens.append({"short_osi": "S2"}) # mutate the caller's list after the call
|
|
opens[0]["last_val"] = 999.0 # mutate a rung dict in the caller's list
|
|
assert len(st["open_spreads"]) == 1
|
|
assert st["open_spreads"][0]["last_val"] == 0.6
|
|
|
|
|
|
def test_build_pos_state_empty_ladder():
|
|
st = build_pos_state([], envelope=0.0, venue="", today="2026-07-13")
|
|
assert st["open_spreads"] == []
|
|
assert st["envelope"] == 0.0
|