139 lines
7.4 KiB
Python
139 lines
7.4 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 _straight_chain(start="2024-07-01", n=10, expiry="2024-07-31", forward=470.0):
|
|
"""A flat-vol synthetic put chain across strikes, plus an ATM call for the parity forward."""
|
|
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: max(0.05, (470 - k) * 0.02 + 0.8) for d in days} # calm decay-ish marks
|
|
contracts.append((osi, expiry, float(k), "P", closes))
|
|
# ATM call so parity_forward(K=470) recovers ~470
|
|
contracts.append((f"XSP{expiry.replace('-','')[2:]}C{470*1000:08d}", expiry, 470.0, "C",
|
|
{d: 0.8 for d in days}))
|
|
return days, contracts
|
|
|
|
|
|
def test_advance_full_history_is_deterministic(tmp_path):
|
|
days, contracts = _straight_chain()
|
|
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 # same frozen input -> identical series
|
|
assert all(isinstance(d, str) and isinstance(x, float) for d, x in a)
|
|
|
|
|
|
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"
|
|
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)
|
|
put_460_mark = (470 - 460) * 0.02 + 0.8 # 1.0, matches the put priced in `_straight_chain`
|
|
contracts.append(("XSP240731C00460000", "2024-07-31", 460.0, "C", {d: put_460_mark + 20.0 for d in 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)
|
|
|
|
|
|
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"
|
|
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_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
|