SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
8.0 KB · 160 lines python
Raw Blame History
1"""Unit tests — roll schedules, depth, gap computation and back/ratio adjustment on synthetic series."""2from __future__ import annotations34from datetime import date56import numpy as np7import pandas as pd8import pytest9from futures.rolls import (10    Segment,11    adjustment_offsets,12    apply_adjustment,13    depth_schedule,14    roll_gaps,15    roll_schedule,16    stitch_daily,17)1819A, B, C = "XXH24", "XXM24", "XXU24"20CONTRACTS = [21    {"symbol": A, "expiration_date": date(2024, 3, 15), "first_notice_date": date(2024, 3, 1), "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 3, 15)},22    {"symbol": B, "expiration_date": date(2024, 6, 21), "first_notice_date": date(2024, 6, 3), "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 6, 21)},23    {"symbol": C, "expiration_date": date(2024, 9, 20), "first_notice_date": None, "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 6, 30)},24]252627def _daily() -> pd.DataFrame:28    """Sessions 2024-01-02 → 2024-06-28; A: close 100 flat, B: 105, C: 110. Volume: A dominates until 03-08,29    B > A on 03-11 and 03-12 (2 consecutive) → volume roll on 03-13. OI: B > A on 03-06/03-07 → roll 03-08."""30    sessions = pd.bdate_range("2024-01-02", "2024-06-28")31    rows = []32    for d in sessions:33        dd = d.date()34        if dd <= date(2024, 3, 15):35            vol_a = 1000 if dd < date(2024, 3, 11) else 10036            oi_a = 5000 if dd < date(2024, 3, 6) else 5037            rows.append({"symbol": A, "date": d, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": vol_a, "open_interest": oi_a})38        if dd <= date(2024, 6, 21):39            rows.append({"symbol": B, "date": d, "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.0, "volume": 500, "open_interest": 2000})40        rows.append({"symbol": C, "date": d, "open": 110.0, "high": 111.0, "low": 109.0, "close": 110.0, "volume": 10, "open_interest": 100})41    return pd.DataFrame(rows)424344def test_calendar_roll():45    segs = roll_schedule(CONTRACTS, _daily(), "calendar")46    assert [s.symbol for s in segs] == [A, B, C]47    assert segs[0].start == date(2024, 1, 2) and segs[0].end == date(2024, 3, 17)   # held through expiry (Fri 15), roll Mon 1848    assert segs[1].start == date(2024, 3, 18) and segs[1].end == date(2024, 6, 23)49    assert segs[2].start == date(2024, 6, 24) and segs[2].end is None505152def test_first_notice_roll_falls_back_to_calendar_when_null():53    segs = roll_schedule(CONTRACTS, _daily(), "first_notice")54    assert segs[0].end == date(2024, 2, 29) and segs[1].start == date(2024, 3, 1)      # roll ON the FND55    assert segs[1].end == date(2024, 6, 2) and segs[2].start == date(2024, 6, 3)565758def test_volume_and_oi_rolls():59    segs = roll_schedule(CONTRACTS, _daily(), "volume")60    assert segs[0].end == date(2024, 3, 12) and segs[1].start == date(2024, 3, 13)61    segs = roll_schedule(CONTRACTS, _daily(), "open_interest")62    assert segs[0].end == date(2024, 3, 7) and segs[1].start == date(2024, 3, 8)63    # B → C: C never beats B on volume before B's expiry → calendar fallback64    segs = roll_schedule(CONTRACTS, _daily(), "volume")65    assert segs[2].start == date(2024, 6, 24)666768def test_volume_roll_ignores_noise_far_from_expiry():69    daily = _daily()70    # a single noisy session in January where B > A must not roll (needs 2 consecutive AND inside the roll window)71    daily.loc[(daily.symbol == A) & (daily.date == "2024-01-10"), "volume"] = 172    segs = roll_schedule(CONTRACTS, daily, "volume")73    assert segs[1].start == date(2024, 3, 13)747576def test_skips_contracts_expired_before_previous_roll():77    contracts = CONTRACTS + [{"symbol": "XXG24", "expiration_date": date(2024, 2, 16), "first_notice_date": None,78                              "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 2, 16)}]79    segs = roll_schedule(contracts, _daily(), "calendar")80    assert [s.symbol for s in segs] == ["XXG24", A, B, C]81    contracts = CONTRACTS + [{"symbol": "XXZ99", "expiration_date": None, "first_notice_date": None, "first_data_date": None, "last_data_date": None}]82    assert [s.symbol for s in roll_schedule(contracts, _daily(), "calendar")] == [A, B, C]838485def test_depth_schedule():86    front = roll_schedule(CONTRACTS, _daily(), "calendar")87    d2 = depth_schedule(front, CONTRACTS, 2)88    assert [(s.symbol, s.start) for s in d2] == [(B, date(2024, 1, 2)), (C, date(2024, 3, 18))]89    d3 = depth_schedule(front, CONTRACTS, 3)90    assert [(s.symbol, s.start, s.end) for s in d3] == [(C, date(2024, 1, 2), date(2024, 3, 17))]91    assert depth_schedule(front, CONTRACTS, 1) is front929394def test_roll_gaps_and_offsets():95    daily = _daily()96    segs = roll_schedule(CONTRACTS, daily, "calendar")97    rolls = roll_gaps(segs, daily)98    assert len(rolls) == 299    assert rolls[0] == {"date": "2024-03-18", "from_symbol": A, "to_symbol": B, "gap": 5.0, "ratio": 1.05, "gap_session": "2024-03-15", "adjusted": True}100    assert rolls[1]["gap"] == 5.0 and rolls[1]["from_symbol"] == B and rolls[1]["to_symbol"] == C101    add, mul = adjustment_offsets(rolls, "back_adjusted")102    assert add == [10.0, 5.0, 0.0] and mul == [1.0, 1.0, 1.0]103    add, mul = adjustment_offsets(rolls, "ratio_adjusted")104    assert add == [0.0, 0.0, 0.0]105    assert mul[2] == 1.0 and mul[1] == pytest.approx(110 / 105) and mul[0] == pytest.approx(1.05 * 110 / 105)106    add, mul = adjustment_offsets(rolls, "none")107    assert add == [0.0, 0.0, 0.0] and mul == [1.0, 1.0, 1.0]108109110def test_stitch_back_adjusted_makes_series_continuous():111    daily = _daily()112    segs = roll_schedule(CONTRACTS, daily, "calendar")113    df, rolls = stitch_daily(segs, daily, "back_adjusted")114    assert len(rolls) == 2115    assert df["datetime"].is_monotonic_increasing and df["datetime"].is_unique116    # latest contract unadjusted, older ones shifted so closes are all 110 → no jump at the rolls117    assert (df["close"].round(9) == 110.0).all()118    assert df.loc[df.symbol == A, "volume"].iloc[0] == 1000          # volume untouched119    df_ratio, _ = stitch_daily(segs, daily, "ratio_adjusted")120    assert np.allclose(df_ratio["close"], 110.0)121    df_none, _ = stitch_daily(segs, daily, "none")122    assert set(df_none["close"].round(6)) == {100.0, 105.0, 110.0}123    # additive adjustment also shifts open/high/low by the same offset124    a_rows = df[df.symbol == A].iloc[0]125    assert a_rows["high"] == pytest.approx(111.0) and a_rows["low"] == pytest.approx(109.0)126127128def test_gap_null_when_no_common_session():129    daily = _daily()130    daily = daily[~((daily.symbol == B) & (daily.date < "2024-03-20"))]        # B has no bars during A's life131    segs = roll_schedule(CONTRACTS, daily, "calendar")132    rolls = roll_gaps(segs, daily)133    assert rolls[0]["gap"] is None and rolls[0]["adjusted"] is False134    add, _ = adjustment_offsets(rolls, "back_adjusted")135    assert add[0] == add[1] == 5.0            # only the B→C gap is applied136137138def test_apply_adjustment_handles_empty_frames():139    df = apply_adjustment([None, pd.DataFrame(columns=["symbol", "datetime", "open", "high", "low", "close", "volume"])], [0, 0], [1, 1])140    assert df.empty141    seg = Segment("X", date(2024, 1, 1), None, 0)142    assert seg.end is None143144145def test_backfill_gap_detection_and_status(app):146    from futures.backfill import (  # needs the test env (SQLite path) set by the app fixture147        _gaps,148        _status,149    )150    days = [date(2024, 12, 20), date(2024, 12, 23), date(2025, 1, 6), date(2025, 1, 7)]151    gaps = _gaps(days, "us")152    assert gaps == [(date(2024, 12, 24), date(2025, 1, 5), 7)]      # 24, 26, 27, 30, 31 Dec, 2, 3 Jan153    assert _gaps([date(2024, 12, 20), date(2024, 12, 27)], "us") == []   # 23, 24, 26 = 3 missing → not a gap154    assert _gaps([], "us") == [] and _gaps([date(2024, 1, 2)], "us") == []155    today = date(2025, 7, 1)156    assert _status(date(2025, 6, 30), date(2025, 9, 19), 2025, 9, today) == "active"157    assert _status(date(2025, 3, 21), date(2025, 3, 21), 2025, 3, today) == "expired"158    assert _status(date(2025, 6, 28), date(2025, 6, 20), 2025, 6, today) == "active"      # recent data → still active159    assert _status(None, None, 2025, 6, today) == "expired"160