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%
7.8 KB · 173 lines python
Raw Blame History
1#!/usr/bin/env python32"""Futures term structure: fetch, quantify (contango/backwardation, spreads, roll yield), compare, plot.34  term_structure.py --root CL [--as-of YYYY-MM-DD] [--compare YYYY-MM-DD] [--depth 12] [--plot out.png] [--json out.json]5"""6from __future__ import annotations78import argparse9import json10import sys11from pathlib import Path1213import numpy as np14import pandas as pd1516sys.path.insert(0, str(Path(__file__).parent))17import hfmd  # noqa: E4021819PRICE_COLS = ("settle", "close", "last", "price")202122def price_col(df: pd.DataFrame) -> str | None:23    return next((c for c in PRICE_COLS if c in df.columns), None)242526def curve(root: str, as_of: str | None, depth: int | None) -> tuple[pd.DataFrame, dict]:27    """v2 term-structure endpoint, with a local rebuild fallback from contracts + bars."""28    try:29        df, meta = hfmd.term_structure(root, as_of)30        if depth:31            df = df.head(depth)32        return df, meta33    except hfmd.HfmdError as e:34        if e.status != 404:35            raise36        hfmd.log("term-structure endpoint unavailable (404) — rebuilding from contracts + bars")37    try:38        cons = hfmd.contracts(root)39    except hfmd.HfmdError as e:40        if e.status == 404:41            raise SystemExit(f"{e}\n→ the v2 futures module (term-structure, contracts) is not deployed on this server yet; nothing to rebuild from.")42        raise43    if cons.empty:44        raise SystemExit("no contracts available")45    ref = pd.Timestamp(as_of) if as_of else pd.Timestamp.today().normalize()46    cons["expiration_date"] = pd.to_datetime(cons["expiration_date"])47    live = cons[cons["expiration_date"] >= ref].sort_values("expiration_date").head(depth or 12)48    rows, used = [], None49    for _, c in live.iterrows():50        b = hfmd.contract_bars(c["symbol"], "1day", str((ref - pd.Timedelta(days=7)).date()), str(ref.date()))51        if b.empty:52            rows.append({"symbol": c["symbol"], "expiration_date": c["expiration_date"], "close": np.nan, "volume": np.nan, "open_interest": np.nan})53            continue54        last = b.iloc[-1]55        used = max(used or last["datetime"], last["datetime"])56        rows.append({"symbol": c["symbol"], "expiration_date": c["expiration_date"], "close": last["close"], "volume": last.get("volume"), "open_interest": last.get("open_interest")})57    return pd.DataFrame(rows), {"as_of": str(used.date()) if used is not None else as_of, "source": "rebuilt-locally"}585960def analyse(df: pd.DataFrame, as_of: str | None) -> tuple[pd.DataFrame, dict]:61    pc = price_col(df)62    if pc is None:63        raise SystemExit(f"no price column in {list(df.columns)}")64    d = df.copy()65    d["expiration_date"] = pd.to_datetime(d.get("expiration_date", d.get("expiry")))66    d = d.sort_values("expiration_date").reset_index(drop=True)67    ref = pd.Timestamp(as_of) if as_of else pd.Timestamp.today().normalize()68    d["dte"] = (d["expiration_date"] - ref).dt.days69    front = float(d[pc].dropna().iloc[0]) if d[pc].notna().any() else np.nan70    d["spread_vs_front"] = d[pc] - front71    d["spread_pct"] = d["spread_vs_front"] / front * 10072    prev_p, prev_dte = d[pc].shift(1), d["dte"].shift(1)73    gap_days = (d["dte"] - prev_dte).replace(0, np.nan)74    d["leg_roll_yield_ann_pct"] = -(d[pc] - prev_p) / prev_p * 365 / gap_days * 10075    legs = d["leg_roll_yield_ann_pct"].dropna()76    up = int((d[pc].diff().dropna() > 0).sum())77    down = int((d[pc].diff().dropna() < 0).sum())78    if up and not down:79        shape = "contango (monotonic upward)"80    elif down and not up:81        shape = "backwardation (monotonic downward)"82    elif up + down == 0:83        shape = "flat"84    else:85        shape = f"mixed / humped ({up} rising legs, {down} falling legs)"86    n6 = min(6, len(d) - 1)87    summary = {88        "shape": shape,89        "front": d["symbol"].iloc[0] if "symbol" in d else None,90        "front_price": front,91        "front_to_%dth_pct" % (n6 + 1): float(d["spread_pct"].iloc[n6]) if n6 > 0 else np.nan,92        "avg_leg_roll_yield_ann_pct": float(legs.mean()) if len(legs) else np.nan,93        "front_leg_roll_yield_ann_pct": float(legs.iloc[0]) if len(legs) else np.nan,94        "legs_without_price": int(d[pc].isna().sum()),95    }96    return d, summary979899def main() -> int:100    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)101    ap.add_argument("--root", required=True)102    ap.add_argument("--as-of")103    ap.add_argument("--compare", help="second date to compare the curve with")104    ap.add_argument("--depth", type=int, default=12)105    ap.add_argument("--plot")106    ap.add_argument("--json")107    a = ap.parse_args()108    root = a.root.upper()109110    df, meta = curve(root, a.as_of, a.depth)111    if df.empty:112        print("empty curve", file=sys.stderr)113        return 1114    used = meta.get("as_of") or a.as_of115    d, s = analyse(df, used)116    pc = price_col(d)117    cols = [c for c in ("symbol", "expiration_date", "dte", pc, "spread_vs_front", "spread_pct", "leg_roll_yield_ann_pct", "volume", "open_interest") if c in d.columns]118    print(f"{root} term structure as of {used}{' (' + meta['source'] + ')' if 'source' in meta else ''}")119    with pd.option_context("display.width", 200, "display.float_format", "{:.3f}".format):120        print(d[cols].to_string(index=False))121    print("\nverdict:", s["shape"])122    for k, v in s.items():123        if k != "shape":124            print(f"  {k}: {v:.3f}" if isinstance(v, float) and np.isfinite(v) else f"  {k}: {v}")125126    d2 = s2 = None127    if a.compare:128        df2, meta2 = curve(root, a.compare, a.depth)129        used2 = meta2.get("as_of") or a.compare130        d2, s2 = analyse(df2, used2)131        print(f"\ncompared with {used2}: {s2['shape']} · front {s2['front_price']:.3f} → {s['front_price']:.3f} ({(s['front_price'] / s2['front_price'] - 1) * 100:+.2f}%)")132        k6 = [k for k in s if k.startswith("front_to_")][0]133        print(f"  slope front→{k6.split('_')[2]}: {s2.get(k6, np.nan):+.2f}% → {s.get(k6, np.nan):+.2f}% (twist {s.get(k6, np.nan) - s2.get(k6, np.nan):+.2f} pp)")134        m = d.merge(d2, on="symbol", suffixes=("", "_prev")) if "symbol" in d and "symbol" in d2 else pd.DataFrame()135        if not m.empty:136            m["chg_pct"] = (m[pc] / m[f"{pc}_prev"] - 1) * 100137            print("  per-contract change (%):", ", ".join(f"{r.symbol} {r.chg_pct:+.1f}" for r in m.itertuples()))138139    if a.json:140        Path(a.json).write_text(json.dumps({"root": root, "as_of": used, "summary": s, "curve": json.loads(d[cols].to_json(orient="records", date_format="iso")),141                                            **({"compare_as_of": a.compare, "compare_summary": s2} if s2 else {})}, indent=1, default=str))142        print(f"wrote {a.json}")143144    if a.plot:145        try:146            import matplotlib147            matplotlib.use("Agg")148            import matplotlib.pyplot as plt149        except ImportError:150            print("matplotlib not installed", file=sys.stderr)151            return 0152        fig, ax = plt.subplots(figsize=(10, 5))153        ax.plot(d["expiration_date"], d[pc], marker="o", lw=1.4, label=f"{used} — {s['shape'].split(' (')[0]}")154        for _, r in d.iterrows():155            if pd.notna(r[pc]) and "symbol" in r:156                ax.annotate(r["symbol"], (r["expiration_date"], r[pc]), textcoords="offset points", xytext=(0, 7), fontsize=7, ha="center")157        if d2 is not None:158            ax.plot(d2["expiration_date"], d2[pc], marker="s", lw=1.0, alpha=0.7, label=f"{a.compare} — {s2['shape'].split(' (')[0]}")159        ax.set_title(f"{root} futures term structure")160        ax.set_xlabel("contract expiry")161        ax.set_ylabel("price")162        ax.grid(alpha=0.25)163        ax.legend()164        fig.autofmt_xdate()165        fig.tight_layout()166        fig.savefig(a.plot, dpi=130)167        print(f"wrote {a.plot}")168    return 0169170171if __name__ == "__main__":172    sys.exit(main())173