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)
JavaScript 53.7%
Python 38.3%
CSS 4.6%
TypeScript 3.1%
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