From 2cea5be70cbf79f8ce321ed4c8cfbcbace3dc4b9 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 12 Jul 2026 22:56:42 +0200 Subject: [PATCH] feat(vrp): xsp_option_bars PIT table + repo (upsert/bars_for/chain_asof) --- .../adapters/persistence/cockpit_models.py | 11 ++++ .../adapters/persistence/xsp_option_bars.py | 54 +++++++++++++++++++ tests/unit/test_xsp_option_bars_repo.py | 41 ++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/fxhnt/adapters/persistence/xsp_option_bars.py create mode 100644 tests/unit/test_xsp_option_bars_repo.py diff --git a/src/fxhnt/adapters/persistence/cockpit_models.py b/src/fxhnt/adapters/persistence/cockpit_models.py index 7b30aa6..199a577 100644 --- a/src/fxhnt/adapters/persistence/cockpit_models.py +++ b/src/fxhnt/adapters/persistence/cockpit_models.py @@ -365,3 +365,14 @@ class StrategyFundingRow(CockpitBase): policy_hash: Mapped[str] = mapped_column(String(64)) as_of: Mapped[str] = mapped_column(String(10)) computed_at: Mapped[dt.datetime] = mapped_column(DateTime) + + +class XspOptionBarRow(CockpitBase): + __tablename__ = "xsp_option_bars" + osi_symbol: Mapped[str] = mapped_column(String(32), primary_key=True) + date: Mapped[dt.date] = mapped_column(Date, primary_key=True) + expiry: Mapped[dt.date] = mapped_column(Date) + strike: Mapped[float] = mapped_column(Float) + right: Mapped[str] = mapped_column(String(1)) + close: Mapped[float] = mapped_column(Float) + src_updated_at: Mapped[dt.datetime] = mapped_column(DateTime) diff --git a/src/fxhnt/adapters/persistence/xsp_option_bars.py b/src/fxhnt/adapters/persistence/xsp_option_bars.py new file mode 100644 index 0000000..a493d20 --- /dev/null +++ b/src/fxhnt/adapters/persistence/xsp_option_bars.py @@ -0,0 +1,54 @@ +"""PIT store for frozen XSP option daily bars — the deterministic substrate the VRP recompute reads. OPRA is +hit only when FILLING this table (asset/backfill); VrpStrategy never calls OPRA. Same freeze discipline as the +equity Yahoo-PIT snapshot.""" +from __future__ import annotations + +import datetime as dt + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from fxhnt.adapters.persistence.cockpit_models import CockpitBase, XspOptionBarRow +from fxhnt.domain.models import OptionContract + + +class XspOptionBarsRepo: + def __init__(self, dsn: str) -> None: + kw = {} if dsn.startswith("postgresql") else {"connect_args": {"check_same_thread": False}} + self._engine = create_engine(dsn, **kw) + + def migrate(self) -> None: + CockpitBase.metadata.create_all(self._engine) + + def upsert_bars(self, rows: list[dict], at: dt.datetime) -> None: + with Session(self._engine) as s: + for r in rows: + obj = XspOptionBarRow( + osi_symbol=r["osi_symbol"], date=dt.date.fromisoformat(r["date"]), + expiry=dt.date.fromisoformat(r["expiry"]), strike=float(r["strike"]), + right=r["right"], close=float(r["close"]), src_updated_at=at) + s.merge(obj) # merge = insert-or-overwrite on the (osi_symbol, date) PK + s.commit() + + def bars_for(self, osi_symbols: list[str], start: str, end: str) -> dict[str, dict[str, float]]: + if not osi_symbols: + return {} + lo, hi = dt.date.fromisoformat(start), dt.date.fromisoformat(end) + out: dict[str, dict[str, float]] = {} + with Session(self._engine) as s: + q = select(XspOptionBarRow).where( + XspOptionBarRow.osi_symbol.in_(osi_symbols), + XspOptionBarRow.date >= lo, XspOptionBarRow.date <= hi) + for row in s.scalars(q): + out.setdefault(row.osi_symbol, {})[row.date.isoformat()] = row.close + return out + + def chain_asof(self, date: str, dte_lo: int, dte_hi: int) -> list[OptionContract]: + d = dt.date.fromisoformat(date) + exp_lo, exp_hi = d + dt.timedelta(days=dte_lo), d + dt.timedelta(days=dte_hi) + with Session(self._engine) as s: + q = select(XspOptionBarRow).where( + XspOptionBarRow.date == d, XspOptionBarRow.right == "P", + XspOptionBarRow.expiry >= exp_lo, XspOptionBarRow.expiry <= exp_hi) + return [OptionContract(osi_symbol=r.osi_symbol, expiry=r.expiry, strike=r.strike, right=r.right) + for r in s.scalars(q)] diff --git a/tests/unit/test_xsp_option_bars_repo.py b/tests/unit/test_xsp_option_bars_repo.py new file mode 100644 index 0000000..5cb3bd9 --- /dev/null +++ b/tests/unit/test_xsp_option_bars_repo.py @@ -0,0 +1,41 @@ +import datetime as dt +from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo + + +def _repo(tmp_path): + r = XspOptionBarsRepo(f"sqlite:///{tmp_path/'o.db'}") + r.migrate() + return r + + +def _bar(osi, date, expiry, strike, right, close): + return dict(osi_symbol=osi, date=date, expiry=expiry, strike=strike, right=right, close=close) + + +def test_upsert_and_bars_for(tmp_path): + r = _repo(tmp_path) + at = dt.datetime(2024, 7, 15) + r.upsert_bars([_bar("XSP240816P00470000", "2024-07-15", "2024-08-16", 470.0, "P", 1.20), + _bar("XSP240816P00470000", "2024-07-16", "2024-08-16", 470.0, "P", 1.35)], at) + assert r.bars_for(["XSP240816P00470000"], "2024-07-15", "2024-07-16") == { + "XSP240816P00470000": {"2024-07-15": 1.20, "2024-07-16": 1.35}} + + +def test_upsert_is_idempotent(tmp_path): + r = _repo(tmp_path) + at = dt.datetime(2024, 7, 15) + r.upsert_bars([_bar("A", "2024-07-15", "2024-08-16", 470.0, "P", 1.20)], at) + r.upsert_bars([_bar("A", "2024-07-15", "2024-08-16", 470.0, "P", 1.25)], at) # same PK -> overwrite + assert r.bars_for(["A"], "2024-07-15", "2024-07-15") == {"A": {"2024-07-15": 1.25}} + + +def test_chain_asof_filters_by_dte_window(tmp_path): + r = _repo(tmp_path) + at = dt.datetime(2024, 7, 15) + r.upsert_bars([ + _bar("P30", "2024-07-15", "2024-08-14", 470.0, "P", 1.2), # 30 DTE -> in [20,45] + _bar("P10", "2024-07-15", "2024-07-25", 470.0, "P", 0.4), # 10 DTE -> out + _bar("P60", "2024-07-15", "2024-09-13", 470.0, "P", 2.0), # 60 DTE -> out + ], at) + got = {c.osi_symbol for c in r.chain_asof("2024-07-15", 20, 45)} + assert got == {"P30"}