215 lines
12 KiB
Python
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
|