Review follow-ups on the VRP model-mark fix. MINOR-1 (reconcile the live exec twin): vrp_exec_record still marked the spread as raw short-long for its 50%-profit-take signal — the same single-leg mark noise removed from the backtest — so a noisy far-OTM long-leg spike could spuriously trip a live close on the paper account. Extract the model-mark valuation into a shared single-source module vrp_marking.py (forward_and_atm_iv, model_spread_value, settlement_value); both vrp_book.VrpStrategy and vrp_exec_record now call it. plan_vrp_ladder takes exec_marks (actual fill basis) AND signal_marks (model); the profit-take decision uses only the model signal, while the close order net limit and realized P&L stay mark-to-actual. Also fixes a latent bug: the freeze band stores near-ATM calls only for DTE[20,45], so a spread aged below 20 DTE has no calls at its own expiry. The prior forward required calls at the spread's expiry -> would have prematurely closed every spread ~20 DTE before expiry. The shared forward now falls back to the globally cleanest ATM pair across expiries (parity forward is spot, common across expiries under r=0), markable at any age. MINOR-2 (efficiency): memoize (F, IV) per (day, expiry) — advance threads a per-day day_cache; plan_and_record_vrp threads a signal_cache — so rungs sharing an expiry don't re-query the chain. MINOR-3 (doc): the absolute defined-risk bound [-width*100, +width*100] holds unconditionally; the tight [-(width-credit)*100, +credit*100] is the clean-BS-surface case (entry model value == credit). Tests: uv run pytest -q -> 1997 passed. New: noisy-actual-mark doesn't trip the exec profit-take; backtest and shared marking are single-source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
6.4 KiB
Python
137 lines
6.4 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, 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"
|
||
# signal above the profit line: the close is driven by DTE<=1, not profit-take; paired with the ACTUAL mark.
|
||
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.9}, {"S1": 0.9}, "2026-07-06", ladder=5,
|
||
entry_weekday=0)
|
||
assert len(to_close) == 1
|
||
assert to_close[0][0] is sp
|
||
assert to_close[0][1] == 0.9 # paired value is the ACTUAL mark (the debit to close)
|
||
|
||
|
||
def test_plan_vrp_ladder_closes_rung_at_profit_take_on_model_signal():
|
||
sp = _rung(credit=1.0)
|
||
# signal_mark <= (1 - profit_take) * credit == 0.50 -> closes; paired with the actual mark (here also 0.40).
|
||
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.40}, {"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_noisy_actual_mark_does_not_trip_profit_take():
|
||
"""THE FIX (MINOR-1): a noisy far-OTM long-leg spike drags the RAW short−long actual mark down under the
|
||
50%-profit line, but the SMOOTH model signal stays above it — so no spurious close. The close decision must
|
||
use the model signal, not the actual mark."""
|
||
sp = _rung(credit=1.0)
|
||
actual = {"S1": 0.30} # raw short−long, dragged low by a long-leg quote spike -> would false-trip
|
||
signal = {"S1": 0.90} # smooth model value, well above the 0.50 profit line
|
||
to_close, _may_open, _slots = plan_vrp_ladder([sp], actual, signal, "2026-07-06", ladder=5,
|
||
profit_take=0.5, entry_weekday=0)
|
||
assert to_close == [] # model signal governs -> no spurious profit-take close
|
||
# and when the MODEL genuinely shows 50% profit, it DOES close (at the actual mark).
|
||
to_close2, _m, _s = plan_vrp_ladder([sp], {"S1": 0.55}, {"S1": 0.45}, "2026-07-06", ladder=5,
|
||
profit_take=0.5, entry_weekday=0)
|
||
assert len(to_close2) == 1 and to_close2[0][1] == 0.55
|
||
|
||
|
||
def test_plan_vrp_ladder_holds_rung_with_no_actual_mark_today():
|
||
sp = _rung()
|
||
# no ACTUAL mark -> HOLD (cannot close without a real price), regardless of the model signal.
|
||
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": None}, {"S1": 0.10}, "2026-07-06", ladder=5,
|
||
entry_weekday=0)
|
||
assert to_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) # 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) # 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 (DTE<=1) -> 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, 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
|