Files
fxhnt/tests/unit/test_vrp_exec_record.py
jgrusewski 38f5dfa784 fix(vrp): single-source model mark for backtest + live exec profit-take
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>
2026-07-15 09:49:45 +02:00

137 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 shortlong 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 shortlong, 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