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%
5.9 KB · 125 lines python
Raw Blame History
1#!/usr/bin/env python32"""Stitch a continuous futures series locally from individual contracts (transparent methodology,3and a fallback when the v2 /continuous endpoint is unavailable).45  stitch_local.py --root CL --start 2023-01-01 [--roll volume|open_interest|calendar] [--roll-days 5]6                  [--adjust none|back_adjusted|ratio] [--out cl_local.csv]78Costs one request per contract in the range (≈ 12/yr for CL, 4/yr for ES) — set HFMD_API_KEY.9"""10from __future__ import annotations1112import argparse13import sys14from pathlib import Path1516import numpy as np17import pandas as pd1819sys.path.insert(0, str(Path(__file__).parent))20import hfmd  # noqa: E402212223def main() -> int:24    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)25    ap.add_argument("--root", required=True)26    ap.add_argument("--start", default="2023-01-01")27    ap.add_argument("--end")28    ap.add_argument("--roll", default="volume", choices=["volume", "open_interest", "calendar"])29    ap.add_argument("--roll-days", type=int, default=5, help="calendar rule: business days before expiration")30    ap.add_argument("--adjust", default="ratio", choices=["none", "back_adjusted", "ratio"])31    ap.add_argument("--out")32    a = ap.parse_args()33    root = a.root.upper()3435    cons = hfmd.contracts(root)36    if cons.empty:37        print("no contracts returned", file=sys.stderr)38        return 139    cons["expiration_date"] = pd.to_datetime(cons["expiration_date"])40    cons["last_data_date"] = pd.to_datetime(cons.get("last_data_date"))41    lo = pd.Timestamp(a.start) - pd.Timedelta(days=120)42    hi = pd.Timestamp(a.end) if a.end else pd.Timestamp.today()43    sel = cons[(cons["expiration_date"] >= lo) & (cons["first_data_date"].pipe(pd.to_datetime) <= hi)].sort_values("expiration_date") if "first_data_date" in cons else cons[cons["expiration_date"] >= lo].sort_values("expiration_date")44    print(f"{root}: {len(sel)} contracts in scope ({sel['symbol'].iloc[0]} … {sel['symbol'].iloc[-1]})")4546    panels: dict[str, pd.DataFrame] = {}47    for sym in sel["symbol"]:48        df = hfmd.contract_bars(sym, "1day", a.start and str((pd.Timestamp(a.start) - pd.Timedelta(days=120)).date()), a.end)49        if df.empty:50            hfmd.log(f"  {sym}: no bars (gap reported, not filled)")51            continue52        panels[sym] = df.set_index("datetime")53    if not panels:54        return 155    order = [s for s in sel["symbol"] if s in panels]56    expiry = dict(zip(sel["symbol"], sel["expiration_date"]))5758    # --- choose the active contract on each date ---------------------------------------------------59    dates = sorted(set().union(*[p.index for p in panels.values()]))60    dates = [d for d in dates if d >= pd.Timestamp(a.start)]61    active_idx, active, rolls = 0, order[0], []62    chosen: list[tuple[pd.Timestamp, str]] = []63    for d in dates:64        # skip contracts already expired65        while active_idx + 1 < len(order) and expiry[active] < d:66            active_idx += 167            active = order[active_idx]68            rolls.append((d, active, "expired"))69        if active_idx + 1 < len(order):70            nxt = order[active_idx + 1]71            switch = False72            if a.roll == "calendar":73                switch = d >= expiry[active] - pd.tseries.offsets.BDay(a.roll_days)74            elif d in panels[active].index and d in panels[nxt].index:75                col = "volume" if a.roll == "volume" else "open_interest"76                if col in panels[nxt].columns and col in panels[active].columns:77                    switch = float(panels[nxt].loc[d, col] or 0) > float(panels[active].loc[d, col] or 0)78            if switch:79                active_idx += 180                active = nxt81                rolls.append((d, active, a.roll))82        chosen.append((d, active))8384    # --- assemble + adjust --------------------------------------------------------------------------85    rows = []86    for d, sym in chosen:87        if d in panels[sym].index:88            r = panels[sym].loc[d]89            rows.append({"datetime": d, "contract": sym, "open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"], "volume": r.get("volume"), "open_interest": r.get("open_interest")})90        else:91            rows.append({"datetime": d, "contract": sym, "open": np.nan, "high": np.nan, "low": np.nan, "close": np.nan, "volume": np.nan, "open_interest": np.nan})92    out = pd.DataFrame(rows).set_index("datetime")9394    if a.adjust != "none":95        adj = out.copy()96        # walk backwards: at each roll, compute the gap between the new and old contract on the roll date97        roll_dates = [d for d, _, _ in rolls if d in out.index]98        for d in reversed(roll_dates):99            i = out.index.get_loc(d)100            new_sym, old_sym = out["contract"].iloc[i], out["contract"].iloc[i - 1] if i > 0 else None101            if not old_sym or d not in panels[old_sym].index or d not in panels[new_sym].index:102                continue103            new_c, old_c = float(panels[new_sym].loc[d, "close"]), float(panels[old_sym].loc[d, "close"])104            before = adj.index < d105            for col in ("open", "high", "low", "close"):106                if a.adjust == "back_adjusted":107                    adj.loc[before, col] = adj.loc[before, col] + (new_c - old_c)108                else:109                    adj.loc[before, col] = adj.loc[before, col] * (new_c / old_c if old_c else 1.0)110        out = adj111112    print(f"\nrolls ({a.roll}{' ' + str(a.roll_days) + 'bd' if a.roll == 'calendar' else ''}, adjust={a.adjust}): {len(rolls)}")113    for d, sym, why in rolls:114        print(f"  {d.date()}  → {sym}  ({why})")115    print(f"\n{len(out):,} rows {out.index[0].date()} → {out.index[-1].date()} · missing closes: {int(out['close'].isna().sum())}")116    print(f"last close (adjusted={a.adjust}): {out['close'].iloc[-1]:.4f} · contract {out['contract'].iloc[-1]}")117    if a.out:118        out.to_csv(a.out)119        print(f"wrote {a.out}")120    return 0121122123if __name__ == "__main__":124    sys.exit(main())125