Files
fxhnt/tests/unit/test_vrp_book.py

215 lines
12 KiB
Python

import datetime as dt
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
from fxhnt.application import vrp_book, vrp_pricing
from fxhnt.application.allocation_engine import cvar_daily
from fxhnt.application.vrp_book import VrpStrategy
def _seed(tmp_path, days, contracts):
"""contracts: list of (osi, expiry, strike, right, {iso_date: close}). Returns a repo."""
r = XspOptionBarsRepo(f"sqlite:///{tmp_path/'v.db'}")
r.migrate()
at = dt.datetime(2024, 1, 1)
rows = []
for osi, expiry, strike, right, closes in contracts:
for d, c in closes.items():
rows.append(dict(osi_symbol=osi, date=d, expiry=expiry, strike=strike, right=right, close=c))
r.upsert_bars(rows, at)
return r
def _put_mark(k: float, day_index: int) -> float:
"""Realistic put mark: OTM puts (low strike) are cheaper than near-ATM puts (high strike, richer
extrinsic value), rising monotonically with strike toward the forward (470) -- e.g. strike 440 ~= 0.15,
465 ~= 0.90, 470 ~= 1.05. Decays ~3%/day (theta): since it's a PERCENTAGE decay, the higher-dollar
near-ATM leg loses more in absolute terms per day than the cheaper far-OTM leg, which is exactly the
source of a short put-credit-spread's daily theta P&L (spread value = short - long shrinks over time)."""
base = 0.15 + 0.03 * (k - 440.0)
return base * (0.97 ** day_index)
def _straight_chain(start="2024-07-01", n=10, expiry="2024-07-31", forward=470.0):
"""A flat-vol synthetic put chain across strikes (OTM puts cheaper, correctly rising toward the
forward), decaying ~3%/day, plus an ATM call tracking the ATM put 1:1 so parity_forward(K=470) recovers
~470 every day. A short-higher-strike/long-lower-strike credit spread's credit is always positive here
since put price increases monotonically with strike (short leg is worth more than the long leg)."""
days = [(dt.date.fromisoformat(start) + dt.timedelta(days=i)).isoformat() for i in range(n)]
contracts = []
for k in range(440, 475, 5): # put strikes below/at forward
osi = f"XSP{expiry.replace('-','')[2:]}P{k*1000:08d}"
closes = {d: _put_mark(float(k), i) for i, d in enumerate(days)}
contracts.append((osi, expiry, float(k), "P", closes))
# ATM call tracks the ATM (470) put mark exactly so parity_forward(K=470) recovers ~470 every day.
contracts.append((f"XSP{expiry.replace('-','')[2:]}C{470*1000:08d}", expiry, 470.0, "C",
{d: _put_mark(470.0, i) for i, d in enumerate(days)}))
return days, contracts
def test_advance_full_history_is_deterministic(tmp_path):
days, contracts = _straight_chain()
r = _seed(tmp_path, days, contracts)
# a spread must actually open on the entry day -- proves the fixture's marks are realistic (positive
# credit), not vacuously producing an empty series (see module docstring history: inverted put pricing
# used to make every credit <= 0 and _select_spread always return None).
opened = VrpStrategy(r, entry_weekday=0)._select_spread(days[0])
assert opened is not None and opened["credit"] > 0.0
a, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
b, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert a == b # same frozen input -> identical series
assert all(isinstance(d, str) and isinstance(x, float) for d, x in a)
assert any(abs(x) > 1e-12 for _, x in a) # a real, non-empty return stream -- not an empty ladder
def test_defined_risk_daily_loss_is_bounded(tmp_path):
# a crash day: the short put mark spikes; the spread loss per day cannot exceed width/margin = 1.0
days, contracts = _straight_chain(n=6)
# overwrite the last day's marks with a crash spike on all puts
crash = days[-1]
for _osi, _expiry, _strike, right, closes in contracts:
if right == "P":
closes[crash] = closes[crash] + 50.0
r = _seed(tmp_path, days, contracts)
series, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert series, "series should not be empty"
# a spread must be open and marked before the crash day, so the bound below is tested against a REAL
# marked position (theta-decay P&L), not an empty ladder that trivially satisfies min() >= -1.0.
assert any(abs(x) > 1e-12 for d, x in series if d != crash)
assert min(x for _, x in series) >= -1.0 - 1e-9 # defined-risk: daily loss per margin bounded
assert max(abs(x) for _, x in series) < 5.0 # sane magnitude
def test_atm_pair_selected_by_min_abs_c_minus_p(tmp_path, monkeypatch):
"""Multiple call strikes exist on the same grid as the puts. The true ATM by min|C-P| is 470 (put and
call both mark 1.0 -> diff=0); 460 has a much larger |C-P| (20.0) that would imply a forward ~10 points
off if it were wrongly picked instead. Spy on `parity_forward` to assert the pairing selects k_atm=470
-- proving selection by min|C-P| over sorted common strikes, not the old minimal-strike-distance
cross-product (which ties at diff==0 for every same-strike pair and silently depended on whatever
order `chain_asof` happened to return rows in)."""
expiry, day = "2024-08-16", "2024-07-15"
contracts = [
("P440", expiry, 440.0, "P", {day: 3.0}),
("P450", expiry, 450.0, "P", {day: 2.0}),
("P460", expiry, 460.0, "P", {day: 2.0}),
("P470", expiry, 470.0, "P", {day: 1.0}),
("C460", expiry, 460.0, "C", {day: 22.0}), # |C-P| = 20 at 460 -- would give forward 480 if picked
("C470", expiry, 470.0, "C", {day: 1.0}), # |C-P| = 0 at 470 -- the true ATM
]
r = _seed(tmp_path, [day], contracts)
strat = VrpStrategy(r)
seen: list[dict] = []
real_parity_forward = vrp_pricing.parity_forward
def spy(**kwargs):
seen.append(kwargs)
return real_parity_forward(**kwargs)
monkeypatch.setattr(vrp_book.vrp_pricing, "parity_forward", spy)
strat._select_spread(day)
assert seen, "parity_forward should have been called"
assert seen[0]["k_atm"] == 470.0
assert seen[0]["call_atm"] == 1.0 and seen[0]["put_atm"] == 1.0
def test_advance_deterministic_with_multiple_common_call_strikes(tmp_path):
"""Guard against the old minimal-strike-distance ATM pairing in the full advance() loop: add a second
call strike (460) that shares a strike with a put but whose mark is far off (|C-P|=20 vs 0 at the true
ATM, 470) -- if it were wrongly picked as ATM, the forward and downstream short-strike selection would
differ. Two fresh `advance(None, {})` runs must stay byte-identical."""
days, contracts = _straight_chain(n=10)
# a second call strike (460) whose |C-P| is always 20 (way off the true ATM's ~0) -- tracks the actual
# decaying 460 put mark each day via `_put_mark` so the offset holds on every day, not just day 0.
contracts.append(("XSP240731C00460000", "2024-07-31", 460.0, "C",
{d: _put_mark(460.0, i) + 20.0 for i, d in enumerate(days)}))
r = _seed(tmp_path, days, contracts)
a, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
b, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert a == b
assert all(isinstance(d, str) and isinstance(x, float) for d, x in a)
assert any(abs(x) > 1e-12 for _, x in a) # canary: a spread actually opened (not a vacuous empty series)
def test_advance_closes_spread_on_missing_marks_not_zombie(tmp_path):
"""A held spread whose legs' marks disappear (e.g. aged/moved out of the frozen slice) must be CLOSED,
not zombie-held forever via the `val is None -> still.append(sp)` branch -- otherwise it permanently
occupies a ladder slot and starves new entries. Truncate all put marks after day 3 so the spread
entered on day 0 (a Monday) has no mark from day 4 onward; assert it is dropped from `open_spreads`
(not carried forward) and the series stays finite."""
days, contracts = _straight_chain(n=10)
stale_after = days[3] # put marks stop appearing after this day
new_contracts = []
for osi, expiry, strike, right, closes in contracts:
if right == "P":
closes = {d: c for d, c in closes.items() if d <= stale_after}
new_contracts.append((osi, expiry, strike, right, closes))
r = _seed(tmp_path, days, new_contracts)
series, extra = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert series, "series should not be empty"
# a spread must actually have OPENED and been marked while the puts were still fresh (days 1-3), so
# dropping it below proves a real held position was closed -- not that an empty ladder stayed empty.
assert any(abs(x) > 1e-12 for d, x in series if d <= stale_after)
assert all(isinstance(x, float) and x == x and abs(x) != float("inf") for _, x in series)
assert extra["open_spreads"] == [] # closed on missing marks, not zombied
def test_advance_dollar_basis_not_100x_understated(tmp_path):
"""Regression for the FIX-2 100x-too-small bug: `pnl`'s numerator must be in DOLLARS (option marks are
per-share; x100 = the contract multiplier) to match `self.margin`'s dollar basis (width_points*100) --
without the x100 conversion the booked fraction is points/dollars, 100x smaller than the true
dollars/margin defined-risk return.
Force exactly ONE spread open (ladder=1) on day0's frozen chain, then overwrite day1's frozen marks for
JUST that spread's two legs so its point-value (short - long) falls by a KNOWN delta=0.05 points (long
leg bumped +0.05, short leg unchanged -- a profit under the short-vol convention). With the default
width_points=5.0 the correct defined-risk return is delta/width = 0.05/5.0 = 0.01 (1%); the OLD buggy
basis (pnl without the x100 conversion, divided by the dollar margin) would have booked
0.01/100 = 0.0001 -- 100x smaller."""
days, contracts = _straight_chain(n=2)
r = _seed(tmp_path, days, contracts)
by_osi = {osi: (expiry, strike, right) for osi, expiry, strike, right, _closes in contracts}
# Determine which spread the selector actually opens on day0 (deterministic given the frozen chain).
probe = VrpStrategy(r, entry_weekday=0, ladder=1)
sp = probe._select_spread(days[0])
assert sp is not None and sp["credit"] > 0.0
short_osi, long_osi = sp["short_osi"], sp["long_osi"]
marks0 = r.bars_for([short_osi, long_osi], days[0], days[0])
short0, long0 = marks0[short_osi][days[0]], marks0[long_osi][days[0]]
assert abs((short0 - long0) - sp["credit"]) < 1e-9
delta_pts = 0.05 # known point-change in the spread's value on day1
day1 = days[1]
s_exp, s_strike, s_right = by_osi[short_osi]
l_exp, l_strike, l_right = by_osi[long_osi]
r.upsert_bars([
dict(osi_symbol=short_osi, date=day1, expiry=s_exp, strike=s_strike, right=s_right, close=short0),
dict(osi_symbol=long_osi, date=day1, expiry=l_exp, strike=l_strike, right=l_right,
close=long0 + delta_pts),
], dt.datetime(2024, 1, 1))
strat = VrpStrategy(r, entry_weekday=0, ladder=1)
series, _ = strat.advance(None, {})
by_day = dict(series)
assert day1 in by_day
width = strat._width # default 5.0
expected = delta_pts / width # dollars/margin: (delta_pts*100) / (width*100)
old_buggy = expected / 100.0 # pre-fix basis: delta_pts / (width*100)
assert abs(by_day[day1] - expected) < 1e-9
assert abs(by_day[day1] - old_buggy) > 50 * abs(old_buggy) # ~100x larger than the old bug would give
def test_fat_left_tail_triggers_cvar_floor():
# A VRP-shaped series: small positive credit-decay most days (net drift ~0), plus occasional deep
# defined-risk losses. cvar_daily averages the worst ceil(0.10*n) returns, so the tail must actually
# be filled with losses: n=50 -> 5 tail slots -> 5 genuine losses. Such a fat-LEFT series has
# cvar_daily/std ABOVE the Gaussian ES-10% multiplier (1.755), which is exactly what makes the merged
# CVaR floor de-lever it (K_cvar < K_vol) where a Gaussian book would be a no-op.
series = [0.06] * 45 + [-0.5] * 5
returns = {str(i): x for i, x in enumerate(series)}
mean = sum(series) / len(series)
std = (sum((x - mean) ** 2 for x in series) / len(series)) ** 0.5
ratio = cvar_daily(returns, 0.10) / std
assert ratio > 1.755 # fatter than Gaussian ES-10% -> CVaR floor bites