skills: pack de 5 skills Claude Code (analyse, backtest walk-forward, futures continus, screener fondamentaux, structure par terme) + zip téléchargeable
- chaque skill : SKILL.md (frontmatter, quand l'utiliser, étapes, exemples, pièges) + scripts Python requests/pandas - helper commun skills/_shared/hfmd.py (enveloppes v1/v2, retry 429, pagination) copié dans chaque skill par le build - skills/scripts/build_skills.py : synchro + zip déterministe → hfmarketdata/web/public/downloads/hfmarketdata-skills.zip - scripts v1 validés en live (SPY 1 405 barres ; walk-forward 14 blocs) ; scripts v2 échouent proprement (404 = module pas encore déployé) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
21 changed files +2,236 −0
added
hfmarketdata/web/public/downloads/hfmarketdata-skills.zip
+0 −0
Binary file not shown.
added
skills/README.md
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +# HF Market Data — skills pack | |
| 2 | + | |
| 3 | +Five Claude Code / Claude skills that turn <https://www.hfmarketdata.io> into a data-analysis workbench. | |
| 4 | +Each skill is a folder with a `SKILL.md` (when to use it, step-by-step, examples) and small Python scripts | |
| 5 | +(`requests` + `pandas`, `matplotlib` optional). No other dependency. | |
| 6 | + | |
| 7 | +| Skill | Use it when you want to… | | |
| 8 | +|---|---| | |
| 9 | +| `hfmd-data-analysis` | pull bars for any symbol into pandas and get returns, volatility, drawdowns, seasonality, a chart | | |
| 10 | +| `hfmd-quick-backtest` | test a moving-average crossover (or any signal) with an honest walk-forward, costs, and a report | | |
| 11 | +| `hfmd-continuous-futures` | build or compare continuous futures series — roll rules, back-adjustment, vs the vendor series | | |
| 12 | +| `hfmd-fundamentals-screen` | screen US stocks on ratios, enrich with statements, keep point-in-time discipline | | |
| 13 | +| `hfmd-term-structure` | draw a futures curve, quantify contango/backwardation and roll yield, compare two dates | | |
| 14 | + | |
| 15 | +## Install | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +# download | |
| 19 | +curl -LO https://www.hfmarketdata.io/downloads/hfmarketdata-skills.zip | |
| 20 | + | |
| 21 | +# for every project (personal skills) | |
| 22 | +unzip -o hfmarketdata-skills.zip -d ~/.claude/skills/ | |
| 23 | + | |
| 24 | +# or for one project only (shared with the team through git) | |
| 25 | +unzip -o hfmarketdata-skills.zip -d .claude/skills/ | |
| 26 | + | |
| 27 | +pip install requests pandas matplotlib # matplotlib only for the charts | |
| 28 | +export HFMD_API_KEY=hfmd_live_… # optional: free account = 120 req/min (keyless = 30 req/h) | |
| 29 | +``` | |
| 30 | + | |
| 31 | +Claude Code lists them under `/skills`; you can also just ask — "backtest a 20/100 MA crossover on SPY since 2015" triggers `hfmd-quick-backtest`. | |
| 32 | + | |
| 33 | +## Layout | |
| 34 | + | |
| 35 | +``` | |
| 36 | +skills/ | |
| 37 | + _shared/hfmd.py single source of the API helper (copied into each skill by the build) | |
| 38 | + hfmd-data-analysis/ SKILL.md · scripts/fetch_bars.py · scripts/analyze.py · scripts/hfmd.py | |
| 39 | + hfmd-quick-backtest/ SKILL.md · scripts/ma_crossover.py · scripts/hfmd.py | |
| 40 | + hfmd-continuous-futures/ SKILL.md · scripts/continuous_compare.py · scripts/stitch_local.py · scripts/hfmd.py | |
| 41 | + hfmd-fundamentals-screen/ SKILL.md · scripts/screen.py · scripts/hfmd.py | |
| 42 | + hfmd-term-structure/ SKILL.md · scripts/term_structure.py · scripts/hfmd.py | |
| 43 | + scripts/build_skills.py syncs _shared/hfmd.py → each skill, zips → web/public/downloads/hfmarketdata-skills.zip | |
| 44 | +``` | |
| 45 | + | |
| 46 | +## Build the zip | |
| 47 | + | |
| 48 | +```bash | |
| 49 | +python3 skills/scripts/build_skills.py # → hfmarketdata/web/public/downloads/hfmarketdata-skills.zip | |
| 50 | +python3 skills/scripts/build_skills.py --check # fail if a copied hfmd.py drifted from _shared/ | |
| 51 | +``` | |
| 52 | + | |
| 53 | +The zip is committed so the website can serve it without a build step. | |
| 54 | + | |
| 55 | +## Conventions the scripts follow | |
| 56 | + | |
| 57 | +- Never invent data: gaps stay `NaN`, and every script prints the row count and the date range it actually received. | |
| 58 | +- Intraday timestamps are naive US/Eastern (as delivered by the API); daily bars are dates. | |
| 59 | +- Respect the quota: keyless is 30 requests/hour — scripts paginate by date only when needed and honour `Retry-After` on 429. | |
| 60 | +- Fundamentals are point-in-time: use `filed_at` (not `period_end`) to decide what was knowable on a given day. | |
added
skills/_shared/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-continuous-futures/SKILL.md
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +--- | |
| 2 | +name: hfmd-continuous-futures | |
| 3 | +description: Build and compare continuous futures series from HF Market Data — server-side v2 continuous endpoint (roll=volume/open_interest/calendar, adjust=none/back_adjusted/ratio, depth) versus the vendor continuous series, or stitched locally from individual contracts with explicit roll dates. Use when the user asks for a continuous futures price, questions how rolls/adjustments were done, or needs a series suitable for backtesting. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# hfmd-continuous-futures | |
| 7 | + | |
| 8 | +A futures "price history" is a construction: someone chose when to jump from one contract to the next | |
| 9 | +(**roll**) and what to do with the price gap at the jump (**adjust**). This skill makes those choices | |
| 10 | +explicit, reproducible and comparable. | |
| 11 | + | |
| 12 | +## Vocabulary (say this to the user) | |
| 13 | + | |
| 14 | +- **Roll rule** — `volume`: switch when the next contract's volume exceeds the front's (most common, tracks where liquidity is); `open_interest`: same with OI (smoother, lags a bit); `calendar`: N business days before expiry/first notice (deterministic, what many CTAs do). | |
| 15 | +- **Adjustment** — `none`: raw prices, discontinuous at rolls (fine for *levels*, wrong for *returns*); `back_adjusted` (additive): shift all earlier history by the roll gap so the series is continuous in **points** (good for P&L in ticks, can go negative on long histories); `ratio` (multiplicative): scale earlier history by the gap ratio, continuous in **percent** (best for returns/backtests, levels are not real prices). | |
| 16 | +- **Depth** — 1 = front month, 2 = second month … (depth 2 avoids expiry noise for spread work). | |
| 17 | +- Vendor series (v1 `/v1/bars/futures/{ROOT}?adjustment=contin_UNadj|contin_adj_ratio|contin_adj_absolute`) are FirstRate Data's own construction — a fixed rule you cannot change. The v2 endpoint lets you pick. | |
| 18 | + | |
| 19 | +## When to use | |
| 20 | + | |
| 21 | +- "Get me continuous ES since 2015", "why does the crude series jump in April?", "back-adjust NG for a backtest", "compare volume-roll vs calendar-roll on CL" | |
| 22 | + | |
| 23 | +## Steps | |
| 24 | + | |
| 25 | +1. Confirm root (`/v1/futures/roots` or `search_symbols(asset=futures)`), timeframe, range, and the intended *use* (levels → `none`; P&L in points → `back_adjusted`; returns → `ratio`). | |
| 26 | +2. Server-side build + comparison: | |
| 27 | + `python3 scripts/continuous_compare.py --root CL --start 2018-01-01 --rolls volume,calendar --adjusts back_adjusted,ratio --vendor contin_adj_ratio --plot cl.png` | |
| 28 | + - prints each series' roll dates (from `meta.roll_dates`), the number of rolls per year, the mean absolute gap at rolls, and the return correlation / tracking difference between the variants and the vendor series. | |
| 29 | +3. If the v2 endpoint is not available on the server yet (404 `NOT_FOUND`), stitch locally to show the methodology: | |
| 30 | + `python3 scripts/stitch_local.py --root CL --start 2023-01-01 --roll volume --adjust ratio --out cl_local.csv` | |
| 31 | + (lists contracts via `/v1/futures/{root}/contracts`, pulls each contract's daily bars, rolls on volume crossover, adjusts; prints the roll table). This costs one request per contract — needs an API key for long histories. | |
| 32 | +4. Report: chosen rule + why, roll dates table, adjusted vs raw last price (to remind that adjusted levels ≠ tradable prices), and any gap in coverage (`/v1/futures/contract/{symbol}/coverage`). | |
| 33 | + | |
| 34 | +## Examples | |
| 35 | + | |
| 36 | +```bash | |
| 37 | +# ES front month, volume roll, ratio-adjusted, daily since 2015 → CSV for a backtest | |
| 38 | +python3 scripts/continuous_compare.py --root ES --start 2015-01-01 --rolls volume --adjusts ratio --out es_cont.csv | |
| 39 | + | |
| 40 | +# Second-month natural gas vs front month (seasonal spread work) | |
| 41 | +python3 scripts/continuous_compare.py --root NG --depth 2 --rolls open_interest --adjusts none --start 2020-01-01 | |
| 42 | + | |
| 43 | +# How different are the vendor's absolute-adjusted and the v2 back_adjusted series? | |
| 44 | +python3 scripts/continuous_compare.py --root CL --rolls volume --adjusts back_adjusted --vendor contin_adj_absolute --start 2019-01-01 | |
| 45 | +``` | |
| 46 | + | |
| 47 | +## Gotchas | |
| 48 | + | |
| 49 | +- Never compute returns on `none`/`contin_UNadj`: the roll gap (often 0.5-3 %) is not a market move. | |
| 50 | +- Back-adjusted (additive) series on long histories can go **negative** (CL 2000-2020 does) — use `ratio` for percent returns. | |
| 51 | +- Roll dates depend on the timeframe used to measure volume (daily is standard); intraday continuous series inherit the daily roll calendar. | |
| 52 | +- Coverage: contracts have data since 2010 (`archive` ≤ 2025 and `update` ≥ 2025 files are already merged by the API); a missing contract is reported as a gap, not filled. | |
| 53 | +- FirstRate roots may differ from exchange codes (`E6` is euro FX `6E`; the API accepts both). | |
added
skills/hfmd-continuous-futures/scripts/continuous_compare.py
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Build server-side continuous futures series (v2) with several roll/adjust choices and compare them | |
| 3 | +to each other and to the vendor continuous series (v1). | |
| 4 | + | |
| 5 | + continuous_compare.py --root CL --start 2018-01-01 --rolls volume,calendar --adjusts back_adjusted,ratio \ | |
| 6 | + [--depth 1] [--timeframe 1day] [--vendor contin_adj_ratio] [--out cl.csv] [--plot cl.png] | |
| 7 | +""" | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import argparse | |
| 11 | +import sys | |
| 12 | +from pathlib import Path | |
| 13 | + | |
| 14 | +import numpy as np | |
| 15 | +import pandas as pd | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 18 | +import hfmd # noqa: E402 | |
| 19 | + | |
| 20 | + | |
| 21 | +def describe_rolls(name: str, df: pd.DataFrame, meta: dict) -> None: | |
| 22 | + rolls = meta.get("roll_dates") or [] | |
| 23 | + if df.empty: | |
| 24 | + print(f"\n[{name}] no data") | |
| 25 | + return | |
| 26 | + years = max((df["datetime"].iloc[-1] - df["datetime"].iloc[0]).days / 365.25, 1e-9) | |
| 27 | + roll_txt = f"{len(rolls)} rolls ({len(rolls) / years:.1f}/yr)" if rolls or not name.startswith("vendor") else "roll dates not exposed (vendor construction)" | |
| 28 | + print(f"\n[{name}] {len(df):,} bars {df['datetime'].iloc[0].date()} → {df['datetime'].iloc[-1].date()} · {roll_txt}") | |
| 29 | + if rolls: | |
| 30 | + shown = rolls if len(rolls) <= 12 else rolls[:6] + ["…"] + rolls[-6:] | |
| 31 | + print(" roll dates:", ", ".join(str(r) if isinstance(r, str) else str(r.get("date", r)) for r in shown)) | |
| 32 | + if "close" in df and len(df) > 1: | |
| 33 | + print(f" last close {df['close'].iloc[-1]:.4f} · first close {df['close'].iloc[0]:.4f}" | |
| 34 | + + (" (adjusted levels are NOT tradable prices)" if "adjust=none" not in name else "")) | |
| 35 | + | |
| 36 | + | |
| 37 | +def main() -> int: | |
| 38 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 39 | + ap.add_argument("--root", required=True) | |
| 40 | + ap.add_argument("--start", default="2015-01-01") | |
| 41 | + ap.add_argument("--end") | |
| 42 | + ap.add_argument("--timeframe", default="1day") | |
| 43 | + ap.add_argument("--depth", type=int, default=1) | |
| 44 | + ap.add_argument("--rolls", default="volume", help="comma list: volume, open_interest, calendar") | |
| 45 | + ap.add_argument("--adjusts", default="back_adjusted", help="comma list: none, back_adjusted, ratio") | |
| 46 | + ap.add_argument("--vendor", help="also fetch the v1 vendor continuous with this adjustment: contin_UNadj, contin_adj_ratio, contin_adj_absolute") | |
| 47 | + ap.add_argument("--out", help="CSV with all series' closes aligned on datetime") | |
| 48 | + ap.add_argument("--plot") | |
| 49 | + a = ap.parse_args() | |
| 50 | + root = a.root.upper() | |
| 51 | + | |
| 52 | + series: dict[str, pd.Series] = {} | |
| 53 | + for roll in a.rolls.split(","): | |
| 54 | + for adj in a.adjusts.split(","): | |
| 55 | + name = f"v2 roll={roll} adjust={adj} depth={a.depth}" | |
| 56 | + try: | |
| 57 | + df, meta = hfmd.continuous(root, roll=roll, adjust=adj, depth=a.depth, timeframe=a.timeframe, start=a.start, end=a.end) | |
| 58 | + except hfmd.HfmdError as e: | |
| 59 | + print(f"\n[{name}] {e}", file=sys.stderr) | |
| 60 | + if e.status == 404: | |
| 61 | + print(" → the v2 continuous endpoint is not deployed on this server yet; use scripts/stitch_local.py", file=sys.stderr) | |
| 62 | + continue | |
| 63 | + describe_rolls(name, df, meta) | |
| 64 | + if not df.empty: | |
| 65 | + series[name] = df.set_index("datetime")["close"].astype(float) | |
| 66 | + | |
| 67 | + if a.vendor: | |
| 68 | + name = f"vendor {a.vendor}" | |
| 69 | + try: | |
| 70 | + vdf = hfmd.bars("futures", root, a.timeframe, a.start, a.end, a.vendor) | |
| 71 | + describe_rolls(name, vdf, {}) | |
| 72 | + if not vdf.empty: | |
| 73 | + series[name] = vdf.set_index("datetime")["close"].astype(float) | |
| 74 | + except hfmd.HfmdError as e: | |
| 75 | + print(f"\n[{name}] {e}", file=sys.stderr) | |
| 76 | + | |
| 77 | + if not series: | |
| 78 | + print("\nno series retrieved", file=sys.stderr) | |
| 79 | + return 1 | |
| 80 | + | |
| 81 | + wide = pd.concat(series, axis=1).sort_index() | |
| 82 | + rets = wide.pct_change() | |
| 83 | + print("\nreturn correlation between series (daily pct changes; 'none'/UNadj variants include roll gaps):") | |
| 84 | + with pd.option_context("display.width", 200): | |
| 85 | + print(rets.corr().round(4).to_string()) | |
| 86 | + if len(series) > 1: | |
| 87 | + names = list(series) | |
| 88 | + base = names[-1] | |
| 89 | + print(f"\nannualised tracking difference of log-returns vs '{base}':") | |
| 90 | + for n in names[:-1]: | |
| 91 | + d = (np.log1p(rets[n]) - np.log1p(rets[base])).dropna() | |
| 92 | + print(f" {n:<45} mean={d.mean() * 252:+.4%}/yr std={d.std() * np.sqrt(252):.4%} n={len(d)}") | |
| 93 | + | |
| 94 | + if a.out: | |
| 95 | + wide.to_csv(a.out) | |
| 96 | + print(f"\nwrote {a.out} ({len(wide):,} rows × {wide.shape[1]} series)") | |
| 97 | + if a.plot: | |
| 98 | + try: | |
| 99 | + import matplotlib | |
| 100 | + matplotlib.use("Agg") | |
| 101 | + import matplotlib.pyplot as plt | |
| 102 | + except ImportError: | |
| 103 | + print("matplotlib not installed", file=sys.stderr) | |
| 104 | + return 0 | |
| 105 | + fig, ax = plt.subplots(figsize=(11, 5)) | |
| 106 | + for n, s in series.items(): | |
| 107 | + ax.plot(s.index, s / s.dropna().iloc[0] * 100, lw=1.0, label=n) | |
| 108 | + ax.set_title(f"{root} continuous series rebased to 100 (differences = roll/adjust methodology)") | |
| 109 | + ax.grid(alpha=0.25) | |
| 110 | + ax.legend(fontsize=8) | |
| 111 | + fig.tight_layout() | |
| 112 | + fig.savefig(a.plot, dpi=130) | |
| 113 | + print(f"wrote {a.plot}") | |
| 114 | + return 0 | |
| 115 | + | |
| 116 | + | |
| 117 | +if __name__ == "__main__": | |
| 118 | + sys.exit(main()) | |
added
skills/hfmd-continuous-futures/scripts/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-continuous-futures/scripts/stitch_local.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Stitch a continuous futures series locally from individual contracts (transparent methodology, | |
| 3 | +and a fallback when the v2 /continuous endpoint is unavailable). | |
| 4 | + | |
| 5 | + 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] | |
| 7 | + | |
| 8 | +Costs one request per contract in the range (≈ 12/yr for CL, 4/yr for ES) — set HFMD_API_KEY. | |
| 9 | +""" | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import argparse | |
| 13 | +import sys | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +import numpy as np | |
| 17 | +import pandas as pd | |
| 18 | + | |
| 19 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 20 | +import hfmd # noqa: E402 | |
| 21 | + | |
| 22 | + | |
| 23 | +def 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() | |
| 34 | + | |
| 35 | + cons = hfmd.contracts(root) | |
| 36 | + if cons.empty: | |
| 37 | + print("no contracts returned", file=sys.stderr) | |
| 38 | + return 1 | |
| 39 | + 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]})") | |
| 45 | + | |
| 46 | + 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 | + continue | |
| 52 | + panels[sym] = df.set_index("datetime") | |
| 53 | + if not panels: | |
| 54 | + return 1 | |
| 55 | + order = [s for s in sel["symbol"] if s in panels] | |
| 56 | + expiry = dict(zip(sel["symbol"], sel["expiration_date"])) | |
| 57 | + | |
| 58 | + # --- 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 expired | |
| 65 | + while active_idx + 1 < len(order) and expiry[active] < d: | |
| 66 | + active_idx += 1 | |
| 67 | + 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 = False | |
| 72 | + 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 += 1 | |
| 80 | + active = nxt | |
| 81 | + rolls.append((d, active, a.roll)) | |
| 82 | + chosen.append((d, active)) | |
| 83 | + | |
| 84 | + # --- 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") | |
| 93 | + | |
| 94 | + 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 date | |
| 97 | + 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 None | |
| 101 | + if not old_sym or d not in panels[old_sym].index or d not in panels[new_sym].index: | |
| 102 | + continue | |
| 103 | + new_c, old_c = float(panels[new_sym].loc[d, "close"]), float(panels[old_sym].loc[d, "close"]) | |
| 104 | + before = adj.index < d | |
| 105 | + 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 = adj | |
| 111 | + | |
| 112 | + 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 0 | |
| 121 | + | |
| 122 | + | |
| 123 | +if __name__ == "__main__": | |
| 124 | + sys.exit(main()) | |
added
skills/hfmd-data-analysis/SKILL.md
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +--- | |
| 2 | +name: hfmd-data-analysis | |
| 3 | +description: Fetch historical OHLCV bars (stocks, ETFs, futures, crypto, indices, FX; 1-minute to daily) from HF Market Data into pandas and produce summary statistics, return/volatility/drawdown analysis and charts. Use when the user asks to analyse, describe, chart or export price history for a symbol or a basket. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# hfmd-data-analysis | |
| 7 | + | |
| 8 | +Pull price history from `https://www.hfmarketdata.io` (v1 `/v1/bars/{asset}/{ticker}`) into a DataFrame and | |
| 9 | +answer "what did this instrument do?" questions with numbers, not impressions. | |
| 10 | + | |
| 11 | +## When to use | |
| 12 | + | |
| 13 | +- "Show me AAPL daily since 2020 and summarise it", "how volatile was BTC in 2024?", "export SPY 5-minute bars for last week to CSV" | |
| 14 | +- Any request that needs bars in pandas before something else (feature engineering, correlations, seasonality) | |
| 15 | +- Not for backtests (use `hfmd-quick-backtest`), continuous futures methodology (`hfmd-continuous-futures`) or curves (`hfmd-term-structure`) | |
| 16 | + | |
| 17 | +## Inputs to confirm with the user | |
| 18 | + | |
| 19 | +| Parameter | Values | Default | | |
| 20 | +|---|---|---| | |
| 21 | +| asset | `stock` `etf` `crypto` `index` `fx` `futures` (vendor continuous) | infer from the symbol | | |
| 22 | +| timeframe | `1min` `5min` `30min` `1hour` `1day` | `1day` | | |
| 23 | +| start / end | `YYYY-MM-DD` (intraday: keep ranges short — one day of 1-min bars ≈ 390 rows RTH, 1 440 for crypto) | last 5 years for daily | | |
| 24 | +| adjustment | stock/etf: `UNADJUSTED` `adj_split` `adj_splitdiv` · futures: `contin_UNadj` `contin_adj_ratio` `contin_adj_absolute` | API default (`adj_splitdiv` for stocks) | | |
| 25 | + | |
| 26 | +## Steps | |
| 27 | + | |
| 28 | +1. Resolve the symbol if unsure: `GET /v1/{asset}/tickers?search=AAP` (or ask). | |
| 29 | +2. Fetch: `python3 scripts/fetch_bars.py --asset stock --ticker AAPL --timeframe 1day --start 2020-01-01 --out aapl.csv` | |
| 30 | + - paginates automatically (keyless max 5 000 rows/request) and prints the exact range received — never assume the range you asked for is the range you got. | |
| 31 | +3. Analyse: `python3 scripts/analyze.py aapl.csv --plot aapl.png` | |
| 32 | + - prints: rows, first/last bar, CAGR, annualised volatility, Sharpe (rf = 0), max drawdown (with dates), best/worst bar, skew/kurtosis, gap count, monthly return table; writes a price + drawdown chart if `--plot`. | |
| 33 | +4. Report the numbers with their window and adjustment; flag missing sessions instead of filling them. | |
| 34 | + | |
| 35 | +## Examples | |
| 36 | + | |
| 37 | +```bash | |
| 38 | +# 1. Five years of daily SPY, summary + chart | |
| 39 | +python3 scripts/fetch_bars.py --asset etf --ticker SPY --start 2021-01-01 --out spy.csv && python3 scripts/analyze.py spy.csv --plot spy.png | |
| 40 | + | |
| 41 | +# 2. One week of 5-minute bars for TSLA (intraday: naive US/Eastern timestamps) | |
| 42 | +python3 scripts/fetch_bars.py --asset stock --ticker TSLA --timeframe 5min --start 2025-08-25 --end 2025-08-29 --out tsla_5m.csv | |
| 43 | + | |
| 44 | +# 3. Vendor continuous crude oil, ratio-adjusted, to parquet | |
| 45 | +python3 scripts/fetch_bars.py --asset futures --ticker CL --adjustment contin_adj_ratio --start 2015-01-01 --out cl.parquet | |
| 46 | + | |
| 47 | +# 4. Several tickers → one long DataFrame | |
| 48 | +python3 scripts/fetch_bars.py --asset stock --ticker AAPL MSFT NVDA --start 2024-01-01 --out mega.csv | |
| 49 | +``` | |
| 50 | + | |
| 51 | +## Gotchas | |
| 52 | + | |
| 53 | +- Intraday `datetime` is naive **US/Eastern** (exchange time); daily bars are plain dates. Localise before joining with UTC data. | |
| 54 | +- Stocks default to split+dividend adjusted prices; say so when quoting historical levels. | |
| 55 | +- Keyless quota: 30 requests/hour, 5 000 rows/request. Set `HFMD_API_KEY` (free account, 120 req/min, 50 000 rows/request) before pulling intraday history. | |
| 56 | +- `volume` for FX/indices may be 0/absent — do not compute volume statistics on them. | |
| 57 | +- Error `TICKER_NOT_FOUND` usually means the symbol exists in another asset class (e.g. `SPY` is `etf`, not `stock`). | |
added
skills/hfmd-data-analysis/scripts/analyze.py
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Summary statistics for a bars file produced by fetch_bars.py (one or several tickers). | |
| 3 | + | |
| 4 | + analyze.py aapl.csv [--plot aapl.png] [--periods-per-year 252] | |
| 5 | + | |
| 6 | +Prints, per ticker: rows, range, CAGR, annualised volatility, Sharpe (rf=0), max drawdown (+ dates), | |
| 7 | +best/worst bar, skew, kurtosis, number of gaps > 3 calendar days, monthly returns table. | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import argparse | |
| 12 | +import sys | |
| 13 | + | |
| 14 | +import numpy as np | |
| 15 | +import pandas as pd | |
| 16 | + | |
| 17 | + | |
| 18 | +def infer_periods_per_year(dt: pd.Series) -> int: | |
| 19 | + if len(dt) < 3: | |
| 20 | + return 252 | |
| 21 | + step = dt.diff().dropna().median() | |
| 22 | + if step >= pd.Timedelta(days=1): | |
| 23 | + return 252 if dt.dt.dayofweek.max() <= 4 else 365 | |
| 24 | + per_day = int(pd.Timedelta(hours=6.5) / step) if step < pd.Timedelta(hours=1) else int(pd.Timedelta(hours=24) / step) | |
| 25 | + return max(per_day, 1) * 252 | |
| 26 | + | |
| 27 | + | |
| 28 | +def stats(df: pd.DataFrame, ppy: int | None) -> dict: | |
| 29 | + df = df.sort_values("datetime").reset_index(drop=True) | |
| 30 | + ppy = ppy or infer_periods_per_year(df["datetime"]) | |
| 31 | + close = df["close"].astype(float) | |
| 32 | + ret = close.pct_change().dropna() | |
| 33 | + years = max((df["datetime"].iloc[-1] - df["datetime"].iloc[0]).days / 365.25, 1e-9) | |
| 34 | + equity = (1 + ret).cumprod() | |
| 35 | + dd = equity / equity.cummax() - 1 | |
| 36 | + trough = dd.idxmin() if len(dd) else None | |
| 37 | + peak = equity.loc[:trough].idxmax() if trough is not None else None | |
| 38 | + gaps = (df["datetime"].diff() > pd.Timedelta(days=3)).sum() | |
| 39 | + out = { | |
| 40 | + "rows": len(df), | |
| 41 | + "first": str(df["datetime"].iloc[0]), | |
| 42 | + "last": str(df["datetime"].iloc[-1]), | |
| 43 | + "periods_per_year": ppy, | |
| 44 | + "total_return": close.iloc[-1] / close.iloc[0] - 1, | |
| 45 | + "cagr": (close.iloc[-1] / close.iloc[0]) ** (1 / years) - 1, | |
| 46 | + "ann_vol": ret.std() * np.sqrt(ppy), | |
| 47 | + "sharpe_rf0": (ret.mean() / ret.std() * np.sqrt(ppy)) if ret.std() > 0 else np.nan, | |
| 48 | + "max_drawdown": dd.min() if len(dd) else np.nan, | |
| 49 | + "dd_peak": str(df["datetime"].iloc[peak]) if peak is not None else None, | |
| 50 | + "dd_trough": str(df["datetime"].iloc[trough]) if trough is not None else None, | |
| 51 | + "best_bar": ret.max(), | |
| 52 | + "worst_bar": ret.min(), | |
| 53 | + "skew": ret.skew(), | |
| 54 | + "kurtosis": ret.kurt(), | |
| 55 | + "gaps_gt_3d": int(gaps), | |
| 56 | + "avg_volume": float(df["volume"].mean()) if "volume" in df else None, | |
| 57 | + } | |
| 58 | + return out | |
| 59 | + | |
| 60 | + | |
| 61 | +def monthly_table(df: pd.DataFrame) -> pd.DataFrame: | |
| 62 | + s = df.set_index("datetime")["close"].astype(float).resample("ME").last().pct_change().dropna() | |
| 63 | + t = s.to_frame("r") | |
| 64 | + t["year"], t["month"] = t.index.year, t.index.month | |
| 65 | + return (t.pivot(index="year", columns="month", values="r") * 100).round(1) | |
| 66 | + | |
| 67 | + | |
| 68 | +def main() -> int: | |
| 69 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 70 | + ap.add_argument("file") | |
| 71 | + ap.add_argument("--plot", help="write a price + drawdown PNG (needs matplotlib)") | |
| 72 | + ap.add_argument("--periods-per-year", type=int) | |
| 73 | + a = ap.parse_args() | |
| 74 | + | |
| 75 | + df = pd.read_parquet(a.file) if a.file.endswith(".parquet") else pd.read_csv(a.file) | |
| 76 | + df["datetime"] = pd.to_datetime(df["datetime"]) | |
| 77 | + tickers = df["ticker"].unique() if "ticker" in df else ["?"] | |
| 78 | + for t in tickers: | |
| 79 | + sub = df[df["ticker"] == t] if "ticker" in df else df | |
| 80 | + s = stats(sub, a.periods_per_year) | |
| 81 | + print(f"\n=== {t} ===") | |
| 82 | + for k, v in s.items(): | |
| 83 | + if isinstance(v, float): | |
| 84 | + print(f"{k:>18}: {v:,.4f}" if abs(v) < 1000 else f"{k:>18}: {v:,.0f}") | |
| 85 | + else: | |
| 86 | + print(f"{k:>18}: {v}") | |
| 87 | + if s["periods_per_year"] <= 365 and len(sub) > 40: | |
| 88 | + print("\nmonthly returns (%):") | |
| 89 | + print(monthly_table(sub).to_string()) | |
| 90 | + | |
| 91 | + if a.plot: | |
| 92 | + try: | |
| 93 | + import matplotlib | |
| 94 | + matplotlib.use("Agg") | |
| 95 | + import matplotlib.pyplot as plt | |
| 96 | + except ImportError: | |
| 97 | + print("matplotlib not installed: pip install matplotlib", file=sys.stderr) | |
| 98 | + return 0 | |
| 99 | + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 6.5), sharex=True, gridspec_kw={"height_ratios": [3, 1]}) | |
| 100 | + for t in tickers: | |
| 101 | + sub = (df[df["ticker"] == t] if "ticker" in df else df).sort_values("datetime") | |
| 102 | + close = sub["close"].astype(float) | |
| 103 | + norm = close / close.iloc[0] * 100 if len(tickers) > 1 else close | |
| 104 | + ax1.plot(sub["datetime"], norm, lw=1.1, label=t) | |
| 105 | + eq = (1 + close.pct_change().fillna(0)).cumprod() | |
| 106 | + ax2.fill_between(sub["datetime"], (eq / eq.cummax() - 1) * 100, 0, alpha=0.35) | |
| 107 | + ax1.set_title(f"{', '.join(tickers)} — {'rebased to 100' if len(tickers) > 1 else 'close'}") | |
| 108 | + ax1.grid(alpha=0.25) | |
| 109 | + ax1.legend(loc="upper left") | |
| 110 | + ax2.set_ylabel("drawdown %") | |
| 111 | + ax2.grid(alpha=0.25) | |
| 112 | + fig.tight_layout() | |
| 113 | + fig.savefig(a.plot, dpi=130) | |
| 114 | + print(f"\nwrote {a.plot}") | |
| 115 | + return 0 | |
| 116 | + | |
| 117 | + | |
| 118 | +if __name__ == "__main__": | |
| 119 | + sys.exit(main()) | |
added
skills/hfmd-data-analysis/scripts/fetch_bars.py
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Fetch OHLCV bars from HF Market Data into CSV or Parquet. | |
| 3 | + | |
| 4 | +Examples: | |
| 5 | + fetch_bars.py --asset stock --ticker AAPL --start 2020-01-01 --out aapl.csv | |
| 6 | + fetch_bars.py --asset stock --ticker AAPL MSFT --timeframe 1hour --start 2025-08-01 --out both.csv | |
| 7 | + fetch_bars.py --asset futures --ticker CL --adjustment contin_adj_ratio --out cl.parquet | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import argparse | |
| 12 | +import sys | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +import pandas as pd | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 18 | +import hfmd # noqa: E402 | |
| 19 | + | |
| 20 | + | |
| 21 | +def main() -> int: | |
| 22 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 23 | + ap.add_argument("--asset", required=True, choices=["stock", "etf", "crypto", "index", "fx", "futures"]) | |
| 24 | + ap.add_argument("--ticker", required=True, nargs="+", help="one or more symbols") | |
| 25 | + ap.add_argument("--timeframe", default="1day", help="1min 5min 30min 1hour 1day (aliases 1m 5m 30m 1h 1d)") | |
| 26 | + ap.add_argument("--start") | |
| 27 | + ap.add_argument("--end") | |
| 28 | + ap.add_argument("--adjustment", help="stock/etf: UNADJUSTED adj_split adj_splitdiv · futures: contin_UNadj contin_adj_ratio contin_adj_absolute") | |
| 29 | + ap.add_argument("--out", required=True, help=".csv or .parquet") | |
| 30 | + a = ap.parse_args() | |
| 31 | + | |
| 32 | + frames = [] | |
| 33 | + for t in a.ticker: | |
| 34 | + df = hfmd.bars(a.asset, t.upper(), a.timeframe, a.start, a.end, a.adjustment) | |
| 35 | + if df.empty: | |
| 36 | + hfmd.log(f"{t}: no rows returned") | |
| 37 | + continue | |
| 38 | + hfmd.log(f"{t}: {len(df):,} bars from {df['datetime'].iloc[0]} to {df['datetime'].iloc[-1]}") | |
| 39 | + frames.append(df) | |
| 40 | + if not frames: | |
| 41 | + return 1 | |
| 42 | + out = pd.concat(frames, ignore_index=True) | |
| 43 | + if a.out.endswith(".parquet"): | |
| 44 | + out.to_parquet(a.out, index=False) | |
| 45 | + else: | |
| 46 | + out.to_csv(a.out, index=False) | |
| 47 | + print(f"wrote {a.out}: {len(out):,} rows, {out['ticker'].nunique()} ticker(s), columns={list(out.columns)}") | |
| 48 | + return 0 | |
| 49 | + | |
| 50 | + | |
| 51 | +if __name__ == "__main__": | |
| 52 | + sys.exit(main()) | |
added
skills/hfmd-data-analysis/scripts/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-fundamentals-screen/SKILL.md
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +--- | |
| 2 | +name: hfmd-fundamentals-screen | |
| 3 | +description: Screen US stocks on fundamental ratios with HF Market Data (PE, PB, EV/EBITDA, FCF yield, ROE, margins, leverage, growth), enrich the hits with their latest ratios and recent quarterly statements, and document the point-in-time caveats (filing dates, restatements). Use when the user asks to "find stocks with…", "screen for…", or compare companies on fundamentals. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# hfmd-fundamentals-screen | |
| 7 | + | |
| 8 | +Run a screener → pull ratios and statements for the survivors → produce a table the user can defend, | |
| 9 | +with the date each number became public. | |
| 10 | + | |
| 11 | +## When to use | |
| 12 | + | |
| 13 | +- "Screen US stocks with PE<15 and FCF yield>6% and show their last 3 quarters" | |
| 14 | +- "Cheapest software companies by EV/EBITDA with ROE > 20 %", "which of these tickers has the best balance sheet?" | |
| 15 | +- Historical: "what would this screen have returned on 2020-03-20?" (`--as-of`, point-in-time) | |
| 16 | + | |
| 17 | +## Filter syntax | |
| 18 | + | |
| 19 | +`filters="pe<15,roe>0.15,fcf_yield>0.06,market_cap>1e9"` — comma-separated `metric<op>value`, ops `< <= > >= =`. | |
| 20 | +Ratios are **decimals** (0.15 = 15 %). Known metrics: `pe pb ps ev_ebitda fcf_yield dividend_yield roe roa gross_margin operating_margin net_margin debt_to_equity current_ratio revenue_growth eps_growth market_cap sector`. | |
| 21 | +Sort: `--sort fcf_yield:desc`. | |
| 22 | + | |
| 23 | +## Steps | |
| 24 | + | |
| 25 | +1. Translate the request into filters (ask for thresholds when vague: "cheap" → `pe<15`? `ev_ebitda<8`?). Convert percentages to decimals. | |
| 26 | +2. Screen + enrich: | |
| 27 | + `python3 scripts/screen.py --filters "pe<15,fcf_yield>0.06" --sort fcf_yield:desc --limit 25 --quarters 3 --out screen.csv` | |
| 28 | + - calls `/v1/fundamentals/screener` (cost: 2 requests), then for each hit `/v1/fundamentals/{t}/ratios` and `/v1/fundamentals/{t}/statements?statement=income&period=quarterly&limit=3` (`--no-enrich` to skip; keyless quota allows ~10 tickers/hour — set `HFMD_API_KEY`). | |
| 29 | + - prints the screen table, then per ticker the last N quarters (revenue, operating income, net income, EPS, filed_at) and a **point-in-time note**: latest `filed_at` used, days since, whether a newer period end exists without a filing yet. | |
| 30 | +3. Present: one table sorted as requested, then the quarterly mini-tables, then caveats. | |
| 31 | + | |
| 32 | +## Point-in-time discipline (always say it) | |
| 33 | + | |
| 34 | +- Every fundamentals row carries `period_end` **and** `filed_at`. A number is knowable only from `filed_at` (10-Q ≈ 40 days, 10-K ≈ 60-90 days after period end). Screens with `--as-of` use what was filed by that date — this is what makes a backtest of the screen honest. | |
| 35 | +- Restatements: `facts/{concept}` returns every reported value for a period; the statements endpoint returns the latest. Mention it when a number looks off. | |
| 36 | +- Price-based ratios (PE, FCF yield…) use the close of `as_of` (or the latest) and the last **reported** TTM figures — not analyst estimates. | |
| 37 | +- Coverage differs by company (`/v1/fundamentals/{t}/coverage`): small caps and recent IPOs have short histories; missing = `null`, never imputed. | |
| 38 | + | |
| 39 | +## Examples | |
| 40 | + | |
| 41 | +```bash | |
| 42 | +# Classic value + quality | |
| 43 | +python3 scripts/screen.py --filters "pe<15,roe>0.15,debt_to_equity<1" --sort pe:asc --limit 20 | |
| 44 | + | |
| 45 | +# Point-in-time: what the screen showed at the March 2020 low | |
| 46 | +python3 scripts/screen.py --filters "fcf_yield>0.08,net_margin>0.1" --as-of 2020-03-20 --no-enrich | |
| 47 | + | |
| 48 | +# Only enrich a list you already have | |
| 49 | +python3 scripts/screen.py --tickers AAPL MSFT GOOGL --quarters 4 | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## Gotchas | |
| 53 | + | |
| 54 | +- A screener 404 means the fundamentals module is not deployed yet on the server; the script says so and exits 2. | |
| 55 | +- `sector` is a string filter (`sector=Technology`); everything else is numeric. | |
| 56 | +- Banks/insurers: EV/EBITDA and FCF yield are not meaningful — prefer PB, ROE. | |
| 57 | +- The screener costs 2 quota requests; with 30/h keyless, do not loop over many parameter sets without a key. | |
added
skills/hfmd-fundamentals-screen/scripts/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-fundamentals-screen/scripts/screen.py
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Fundamental screener + enrichment with point-in-time notes. | |
| 3 | + | |
| 4 | + screen.py --filters "pe<15,fcf_yield>0.06" [--sort fcf_yield:desc] [--as-of 2020-03-20] [--limit 25] | |
| 5 | + [--quarters 3] [--no-enrich] [--out screen.csv] | |
| 6 | + screen.py --tickers AAPL MSFT --quarters 4 # enrich only | |
| 7 | +""" | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import argparse | |
| 11 | +import sys | |
| 12 | +from datetime import date | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +import pandas as pd | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 18 | +import hfmd # noqa: E402 | |
| 19 | + | |
| 20 | +KEY_RATIOS = ["pe", "pb", "ev_ebitda", "fcf_yield", "dividend_yield", "roe", "roa", "gross_margin", "operating_margin", "net_margin", "debt_to_equity", "current_ratio", "revenue_growth", "eps_growth", "market_cap"] | |
| 21 | +INCOME_COLS = ["period_end", "fiscal_period", "revenue", "operating_income", "net_income", "eps_diluted", "filed_at", "form"] | |
| 22 | + | |
| 23 | + | |
| 24 | +def pit_note(q: pd.DataFrame) -> str: | |
| 25 | + if q.empty or "filed_at" not in q: | |
| 26 | + return "no quarterly statements available" | |
| 27 | + q = q.copy() | |
| 28 | + q["filed_at"] = pd.to_datetime(q["filed_at"], errors="coerce") | |
| 29 | + q["period_end"] = pd.to_datetime(q.get("period_end"), errors="coerce") | |
| 30 | + latest = q.sort_values("filed_at").iloc[-1] | |
| 31 | + lag = (latest["filed_at"] - latest["period_end"]).days if pd.notna(latest["filed_at"]) and pd.notna(latest["period_end"]) else None | |
| 32 | + age = (pd.Timestamp(date.today()) - latest["filed_at"]).days if pd.notna(latest["filed_at"]) else None | |
| 33 | + return (f"latest period {latest['period_end'].date() if pd.notna(latest['period_end']) else '?'} became public on " | |
| 34 | + f"{latest['filed_at'].date() if pd.notna(latest['filed_at']) else '?'} ({lag} days after period end; {age} days ago)" | |
| 35 | + + (" — a newer quarter has ended but is not filed yet" if age is not None and age > 100 else "")) | |
| 36 | + | |
| 37 | + | |
| 38 | +def main() -> int: | |
| 39 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 40 | + ap.add_argument("--filters", help='e.g. "pe<15,roe>0.15,fcf_yield>0.06"') | |
| 41 | + ap.add_argument("--sort", help="metric:asc|desc") | |
| 42 | + ap.add_argument("--as-of", help="point-in-time date YYYY-MM-DD") | |
| 43 | + ap.add_argument("--limit", type=int, default=25) | |
| 44 | + ap.add_argument("--tickers", nargs="*", help="skip the screener, enrich these tickers") | |
| 45 | + ap.add_argument("--quarters", type=int, default=3) | |
| 46 | + ap.add_argument("--no-enrich", action="store_true") | |
| 47 | + ap.add_argument("--out") | |
| 48 | + a = ap.parse_args() | |
| 49 | + if not a.filters and not a.tickers: | |
| 50 | + ap.error("--filters or --tickers required") | |
| 51 | + | |
| 52 | + hits = pd.DataFrame() | |
| 53 | + if a.filters: | |
| 54 | + try: | |
| 55 | + hits, meta = hfmd.screener(a.filters, a.sort, a.as_of, a.limit) | |
| 56 | + except hfmd.HfmdError as e: | |
| 57 | + print(e, file=sys.stderr) | |
| 58 | + if e.status == 404: | |
| 59 | + print("→ the fundamentals module is not deployed on this server yet (v2 rollout). Nothing to screen.", file=sys.stderr) | |
| 60 | + return 2 | |
| 61 | + return 1 | |
| 62 | + print(f"screen `{a.filters}`{' as of ' + a.as_of if a.as_of else ''}: {meta.get('count', len(hits))} match(es)" | |
| 63 | + + (f" (universe {meta['universe']})" if 'universe' in meta else "")) | |
| 64 | + if hits.empty: | |
| 65 | + return 0 | |
| 66 | + with pd.option_context("display.width", 200, "display.max_columns", 30, "display.float_format", "{:.3f}".format): | |
| 67 | + print(hits.head(a.limit).to_string(index=False)) | |
| 68 | + tickers = list(hits["ticker"].head(a.limit)) if "ticker" in hits else [] | |
| 69 | + else: | |
| 70 | + tickers = [t.upper() for t in a.tickers] | |
| 71 | + | |
| 72 | + if a.no_enrich or not tickers: | |
| 73 | + if a.out and not hits.empty: | |
| 74 | + hits.to_csv(a.out, index=False) | |
| 75 | + print(f"wrote {a.out}") | |
| 76 | + return 0 | |
| 77 | + | |
| 78 | + if not hfmd.API_KEY and len(tickers) > 8: | |
| 79 | + hfmd.log(f"keyless mode: enriching {len(tickers)} tickers needs {2 * len(tickers)} requests (30/h quota) — set HFMD_API_KEY or use --limit 8") | |
| 80 | + | |
| 81 | + enriched = [] | |
| 82 | + for t in tickers: | |
| 83 | + print(f"\n=== {t} ===") | |
| 84 | + try: | |
| 85 | + r = hfmd.ratios(t) | |
| 86 | + line = " ".join(f"{k}={r[k]:.3g}" for k in KEY_RATIOS if isinstance(r.get(k), (int, float))) | |
| 87 | + print("ratios:", line or r) | |
| 88 | + if r.get("as_of") or r.get("period_end"): | |
| 89 | + print(f" based on period {r.get('period_end')} · filed {r.get('filed_at')} · price as of {r.get('as_of')}") | |
| 90 | + except hfmd.HfmdError as e: | |
| 91 | + r = {} | |
| 92 | + print(f"ratios: {e}") | |
| 93 | + try: | |
| 94 | + q = hfmd.statements(t, "income", "quarterly", a.quarters) | |
| 95 | + cols = [c for c in INCOME_COLS if c in q.columns] or list(q.columns)[:8] | |
| 96 | + with pd.option_context("display.width", 200, "display.float_format", "{:,.0f}".format): | |
| 97 | + print(q[cols].to_string(index=False) if not q.empty else " no statements") | |
| 98 | + print(" point-in-time:", pit_note(q)) | |
| 99 | + except hfmd.HfmdError as e: | |
| 100 | + q = pd.DataFrame() | |
| 101 | + print(f"statements: {e}") | |
| 102 | + enriched.append({"ticker": t, **{k: r.get(k) for k in KEY_RATIOS}, "latest_filed_at": (q["filed_at"].max() if "filed_at" in q else None)}) | |
| 103 | + | |
| 104 | + if a.out: | |
| 105 | + out = pd.DataFrame(enriched) | |
| 106 | + if not hits.empty and "ticker" in hits: | |
| 107 | + out = hits.merge(out, on="ticker", how="left", suffixes=("", "_latest")) | |
| 108 | + out.to_csv(a.out, index=False) | |
| 109 | + print(f"\nwrote {a.out}") | |
| 110 | + return 0 | |
| 111 | + | |
| 112 | + | |
| 113 | +if __name__ == "__main__": | |
| 114 | + sys.exit(main()) | |
added
skills/hfmd-quick-backtest/SKILL.md
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +--- | |
| 2 | +name: hfmd-quick-backtest | |
| 3 | +description: Backtest a moving-average crossover (or adapt the template to another signal) on HF Market Data bars with an honest walk-forward (parameters chosen on a training window, evaluated on the next out-of-sample window), transaction costs, and a full metrics report. Use when the user wants to "test a strategy", "backtest", or compare signal parameters on a symbol. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# hfmd-quick-backtest | |
| 7 | + | |
| 8 | +A small, honest backtest template on top of v1 bars. It exists to give a *defensible* first answer | |
| 9 | +(walk-forward, costs, benchmark, no look-ahead) — not to be a full trading framework. | |
| 10 | + | |
| 11 | +## When to use | |
| 12 | + | |
| 13 | +- "Backtest a 50/200 MA crossover on SPY", "does a 20/100 cross work on CL?", "which MA pair worked best on BTC — and did it hold out of sample?" | |
| 14 | +- Any quick strategy sanity check on daily or intraday bars | |
| 15 | + | |
| 16 | +## Method (what the script does) | |
| 17 | + | |
| 18 | +1. Fetch bars (`/v1/bars/{asset}/{ticker}`), keep `close`, compute daily/bar returns. | |
| 19 | +2. **Walk-forward**: split time into consecutive blocks (`--test-years`, default 1). For each test block, select the (fast, slow) pair with the best Sharpe over the preceding `--train-years` (default 3) of data from the `--grid`; apply *that* pair to the test block. Concatenate the out-of-sample test blocks → the reported equity curve. | |
| 20 | +3. Signal: long when `MA_fast > MA_slow`, flat otherwise (`--allow-short` for ±1). Position is applied to the **next bar** (no look-ahead). | |
| 21 | +4. Costs: `--cost-bps` per side per change of position (default 5 bps). | |
| 22 | +5. Report: CAGR, annualised volatility, Sharpe, max drawdown, exposure, number of trades, turnover, vs buy-and-hold on the same out-of-sample span; per-block table of the chosen parameters and the block's Sharpe (this is where you see whether the choice was stable). | |
| 23 | +6. Optional chart: equity vs benchmark + drawdown + the chosen pair over time. | |
| 24 | + | |
| 25 | +## Steps | |
| 26 | + | |
| 27 | +```bash | |
| 28 | +python3 scripts/ma_crossover.py --asset etf --ticker SPY --start 2010-01-01 \ | |
| 29 | + --grid 10,20,50 --grid-slow 100,150,200 --train-years 3 --test-years 1 --cost-bps 5 --plot spy_wf.png | |
| 30 | +``` | |
| 31 | + | |
| 32 | +Then explain: (a) the out-of-sample numbers *only*, (b) the stability of the selected parameters across blocks, (c) the in-sample vs out-of-sample gap (`--show-insample` prints the best in-sample pair on the full history for contrast), (d) what is not modelled (slippage beyond bps, borrow, dividends if UNADJUSTED, intraday fills). | |
| 33 | + | |
| 34 | +## Examples | |
| 35 | + | |
| 36 | +```bash | |
| 37 | +# Crude oil vendor continuous (ratio-adjusted so returns are meaningful across rolls) | |
| 38 | +python3 scripts/ma_crossover.py --asset futures --ticker CL --adjustment contin_adj_ratio --start 2012-01-01 --allow-short | |
| 39 | + | |
| 40 | +# Bitcoin, hourly bars, 6-month train / 2-month test | |
| 41 | +python3 scripts/ma_crossover.py --asset crypto --ticker BTCUSD --timeframe 1hour --start 2024-01-01 --train-years 0.5 --test-years 0.17 --grid 12,24,48 --grid-slow 96,168,336 | |
| 42 | + | |
| 43 | +# Single fixed pair, no optimisation (pure evaluation) | |
| 44 | +python3 scripts/ma_crossover.py --asset stock --ticker AAPL --fixed 50,200 --start 2015-01-01 | |
| 45 | +``` | |
| 46 | + | |
| 47 | +## Adapting the template | |
| 48 | + | |
| 49 | +`signal_ma_cross(close, fast, slow)` returns a position series in {0,1} (or {-1,0,1}). Replace it with any function of past data only; keep the `.shift(1)` when applying positions and keep the walk-forward loop untouched. | |
| 50 | + | |
| 51 | +## Gotchas | |
| 52 | + | |
| 53 | +- For futures use `contin_adj_ratio` (multiplicative back-adjustment): `contin_UNadj` has roll jumps that fake returns; `contin_adj_absolute` can go negative in long histories. | |
| 54 | +- Intraday bars are US/Eastern and include only the sessions the vendor covers; annualisation uses bars/year inferred from the median bar spacing — check the printed `periods_per_year`. | |
| 55 | +- Keyless quota (30 req/h) is enough for daily history; hourly since 2010 needs an API key (`HFMD_API_KEY`). | |
| 56 | +- A Sharpe above ~1.5 out-of-sample on a plain MA cross is a red flag for a bug or a tiny sample, not a discovery. | |
added
skills/hfmd-quick-backtest/scripts/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-quick-backtest/scripts/ma_crossover.py
+182 −0
@@ -0,0 +1,182 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Walk-forward moving-average crossover backtest on HF Market Data bars. | |
| 3 | + | |
| 4 | + ma_crossover.py --asset etf --ticker SPY --start 2010-01-01 [--grid 10,20,50 --grid-slow 100,150,200] | |
| 5 | + [--train-years 3 --test-years 1] [--cost-bps 5] [--allow-short] [--fixed 50,200] [--plot out.png] | |
| 6 | + | |
| 7 | +The reported metrics are OUT-OF-SAMPLE: for each test block the (fast, slow) pair is chosen on the | |
| 8 | +preceding training window only. Positions apply to the next bar (no look-ahead). | |
| 9 | +""" | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import argparse | |
| 13 | +import itertools | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +import numpy as np | |
| 18 | +import pandas as pd | |
| 19 | + | |
| 20 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 21 | +import hfmd # noqa: E402 | |
| 22 | + | |
| 23 | + | |
| 24 | +def signal_ma_cross(close: pd.Series, fast: int, slow: int, allow_short: bool) -> pd.Series: | |
| 25 | + """Position in {0,1} (or {-1,0,1}) from past data only. Replace to test another idea.""" | |
| 26 | + f, s = close.rolling(fast).mean(), close.rolling(slow).mean() | |
| 27 | + pos = (f > s).astype(float) | |
| 28 | + if allow_short: | |
| 29 | + pos = pos - (f < s).astype(float) | |
| 30 | + return pos | |
| 31 | + | |
| 32 | + | |
| 33 | +def run(close: pd.Series, pos: pd.Series, cost_bps: float) -> pd.DataFrame: | |
| 34 | + ret = close.pct_change().fillna(0.0) | |
| 35 | + p = pos.shift(1).fillna(0.0) # decide at t, hold from t+1 | |
| 36 | + trades = p.diff().abs().fillna(0.0) | |
| 37 | + strat = p * ret - trades * cost_bps / 1e4 | |
| 38 | + return pd.DataFrame({"ret": ret, "pos": p, "strat": strat, "trades": trades}) | |
| 39 | + | |
| 40 | + | |
| 41 | +def periods_per_year(dt: pd.Series) -> int: | |
| 42 | + step = dt.diff().dropna().median() | |
| 43 | + if step >= pd.Timedelta(days=1): | |
| 44 | + return 252 if dt.dt.dayofweek.max() <= 4 else 365 | |
| 45 | + per_day = int(pd.Timedelta(hours=6.5) / step) if step < pd.Timedelta(hours=1) else int(pd.Timedelta(hours=24) / step) | |
| 46 | + return max(per_day, 1) * 252 | |
| 47 | + | |
| 48 | + | |
| 49 | +def metrics(r: pd.Series, ppy: int) -> dict: | |
| 50 | + if len(r) < 2: | |
| 51 | + return {"cagr": np.nan, "vol": np.nan, "sharpe": np.nan, "max_dd": np.nan} | |
| 52 | + eq = (1 + r).cumprod() | |
| 53 | + years = len(r) / ppy | |
| 54 | + return { | |
| 55 | + "cagr": eq.iloc[-1] ** (1 / years) - 1 if years > 0 else np.nan, | |
| 56 | + "vol": r.std() * np.sqrt(ppy), | |
| 57 | + "sharpe": r.mean() / r.std() * np.sqrt(ppy) if r.std() > 0 else np.nan, | |
| 58 | + "max_dd": (eq / eq.cummax() - 1).min(), | |
| 59 | + } | |
| 60 | + | |
| 61 | + | |
| 62 | +def main() -> int: | |
| 63 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 64 | + ap.add_argument("--asset", required=True, choices=["stock", "etf", "crypto", "index", "fx", "futures"]) | |
| 65 | + ap.add_argument("--ticker", required=True) | |
| 66 | + ap.add_argument("--timeframe", default="1day") | |
| 67 | + ap.add_argument("--start", default="2010-01-01") | |
| 68 | + ap.add_argument("--end") | |
| 69 | + ap.add_argument("--adjustment", help="futures: prefer contin_adj_ratio") | |
| 70 | + ap.add_argument("--grid", default="10,20,50", help="fast MA candidates") | |
| 71 | + ap.add_argument("--grid-slow", default="100,150,200", help="slow MA candidates") | |
| 72 | + ap.add_argument("--fixed", help="fast,slow — skip optimisation, evaluate one pair on the whole history") | |
| 73 | + ap.add_argument("--train-years", type=float, default=3.0) | |
| 74 | + ap.add_argument("--test-years", type=float, default=1.0) | |
| 75 | + ap.add_argument("--cost-bps", type=float, default=5.0, help="cost per side per unit of position change") | |
| 76 | + ap.add_argument("--allow-short", action="store_true") | |
| 77 | + ap.add_argument("--show-insample", action="store_true", help="also print the best pair on the full history (for contrast)") | |
| 78 | + ap.add_argument("--plot") | |
| 79 | + ap.add_argument("--out-csv", help="write the out-of-sample equity curve") | |
| 80 | + a = ap.parse_args() | |
| 81 | + | |
| 82 | + adj = a.adjustment or ("contin_adj_ratio" if a.asset == "futures" else None) | |
| 83 | + df = hfmd.bars(a.asset, a.ticker.upper(), a.timeframe, a.start, a.end, adj) | |
| 84 | + if len(df) < 300: | |
| 85 | + print(f"only {len(df)} bars — too short for a walk-forward", file=sys.stderr) | |
| 86 | + return 1 | |
| 87 | + df = df.set_index("datetime") | |
| 88 | + close = df["close"].astype(float) | |
| 89 | + ppy = periods_per_year(pd.Series(df.index)) | |
| 90 | + print(f"{a.ticker.upper()} {a.timeframe} {adj or ''}: {len(close):,} bars {close.index[0].date()} → {close.index[-1].date()} · periods/year={ppy} · cost={a.cost_bps} bps/side") | |
| 91 | + | |
| 92 | + fasts = [int(x) for x in a.grid.split(",")] | |
| 93 | + slows = [int(x) for x in a.grid_slow.split(",")] | |
| 94 | + pairs = [(f, s) for f, s in itertools.product(fasts, slows) if f < s] | |
| 95 | + | |
| 96 | + if a.fixed: | |
| 97 | + f, s = (int(x) for x in a.fixed.split(",")) | |
| 98 | + res = run(close, signal_ma_cross(close, f, s, a.allow_short), a.cost_bps).iloc[s:] | |
| 99 | + blocks = pd.DataFrame([{"test_start": res.index[0].date(), "test_end": res.index[-1].date(), "fast": f, "slow": s, **{f"oos_{k}": v for k, v in metrics(res["strat"], ppy).items()}}]) | |
| 100 | + else: | |
| 101 | + train_n, test_n = int(a.train_years * ppy), int(a.test_years * ppy) | |
| 102 | + if train_n < max(slows) + 20: | |
| 103 | + print(f"train window ({train_n} bars) too short for slow MA {max(slows)}", file=sys.stderr) | |
| 104 | + return 1 | |
| 105 | + pieces, rows = [], [] | |
| 106 | + start = train_n | |
| 107 | + while start + 20 < len(close): | |
| 108 | + tr = close.iloc[start - train_n:start] | |
| 109 | + best, best_sh = None, -np.inf | |
| 110 | + for f, s in pairs: | |
| 111 | + sh = metrics(run(tr, signal_ma_cross(tr, f, s, a.allow_short), a.cost_bps)["strat"].iloc[s:], ppy)["sharpe"] | |
| 112 | + if np.isfinite(sh) and sh > best_sh: | |
| 113 | + best, best_sh = (f, s), sh | |
| 114 | + f, s = best or pairs[0] | |
| 115 | + # compute the signal on history + test block so the MAs are warm at the block start | |
| 116 | + seg = close.iloc[max(0, start - s - 5):start + test_n] | |
| 117 | + res = run(seg, signal_ma_cross(seg, f, s, a.allow_short), a.cost_bps).loc[close.index[start]:] | |
| 118 | + pieces.append(res) | |
| 119 | + m = metrics(res["strat"], ppy) | |
| 120 | + rows.append({"test_start": res.index[0].date(), "test_end": res.index[-1].date(), "fast": f, "slow": s, "train_sharpe": round(best_sh, 2), **{f"oos_{k}": v for k, v in m.items()}}) | |
| 121 | + start += test_n | |
| 122 | + if not pieces: | |
| 123 | + print("not enough data for one test block", file=sys.stderr) | |
| 124 | + return 1 | |
| 125 | + res = pd.concat(pieces) | |
| 126 | + blocks = pd.DataFrame(rows) | |
| 127 | + | |
| 128 | + strat, bench = metrics(res["strat"], ppy), metrics(res["ret"], ppy) | |
| 129 | + n_trades = int((res["trades"] > 0).sum()) | |
| 130 | + print(f"\nOUT-OF-SAMPLE {res.index[0].date()} → {res.index[-1].date()} ({len(res):,} bars, {len(blocks)} block(s))") | |
| 131 | + print(f"{'':14}{'strategy':>12}{'buy&hold':>12}") | |
| 132 | + for k in ("cagr", "vol", "sharpe", "max_dd"): | |
| 133 | + print(f"{k:<14}{strat[k]:>12.3f}{bench[k]:>12.3f}") | |
| 134 | + print(f"{'exposure':<14}{res['pos'].abs().mean():>12.2f}") | |
| 135 | + print(f"{'trades':<14}{n_trades:>12d} turnover/yr={res['trades'].sum() / (len(res) / ppy):.1f}") | |
| 136 | + print("\nper block (parameters chosen on the preceding training window):") | |
| 137 | + with pd.option_context("display.width", 160, "display.float_format", "{:.3f}".format): | |
| 138 | + print(blocks.to_string(index=False)) | |
| 139 | + | |
| 140 | + if a.show_insample and not a.fixed: | |
| 141 | + best = max(pairs, key=lambda p: metrics(run(close, signal_ma_cross(close, *p, a.allow_short), a.cost_bps)["strat"].iloc[p[1]:], ppy)["sharpe"] or -np.inf) | |
| 142 | + m = metrics(run(close, signal_ma_cross(close, *best, a.allow_short), a.cost_bps)["strat"].iloc[best[1]:], ppy) | |
| 143 | + print(f"\nIN-SAMPLE best pair on full history (optimistic, for contrast): {best} sharpe={m['sharpe']:.2f} cagr={m['cagr']:.3f} max_dd={m['max_dd']:.3f}") | |
| 144 | + | |
| 145 | + if a.out_csv: | |
| 146 | + out = res.copy() | |
| 147 | + out["equity"] = (1 + out["strat"]).cumprod() | |
| 148 | + out["benchmark"] = (1 + out["ret"]).cumprod() | |
| 149 | + out.to_csv(a.out_csv) | |
| 150 | + print(f"wrote {a.out_csv}") | |
| 151 | + | |
| 152 | + if a.plot: | |
| 153 | + try: | |
| 154 | + import matplotlib | |
| 155 | + matplotlib.use("Agg") | |
| 156 | + import matplotlib.pyplot as plt | |
| 157 | + except ImportError: | |
| 158 | + print("matplotlib not installed", file=sys.stderr) | |
| 159 | + return 0 | |
| 160 | + eq, bm = (1 + res["strat"]).cumprod(), (1 + res["ret"]).cumprod() | |
| 161 | + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 6.5), sharex=True, gridspec_kw={"height_ratios": [3, 1]}) | |
| 162 | + ax1.plot(eq.index, eq, lw=1.3, label=f"MA cross walk-forward (Sharpe {strat['sharpe']:.2f})") | |
| 163 | + ax1.plot(bm.index, bm, lw=1.0, alpha=0.7, label=f"buy & hold (Sharpe {bench['sharpe']:.2f})") | |
| 164 | + if not a.fixed: | |
| 165 | + for _, b in blocks.iterrows(): | |
| 166 | + ax1.axvline(pd.Timestamp(b["test_start"]), color="grey", alpha=0.25, lw=0.8) | |
| 167 | + ax1.text(pd.Timestamp(b["test_start"]), ax1.get_ylim()[0], f"{b['fast']}/{b['slow']}", fontsize=7, rotation=90, va="bottom", alpha=0.7) | |
| 168 | + ax1.set_yscale("log") | |
| 169 | + ax1.grid(alpha=0.25) | |
| 170 | + ax1.legend(loc="upper left") | |
| 171 | + ax1.set_title(f"{a.ticker.upper()} — out-of-sample equity (log), cost {a.cost_bps} bps/side") | |
| 172 | + ax2.fill_between(eq.index, (eq / eq.cummax() - 1) * 100, 0, alpha=0.4) | |
| 173 | + ax2.set_ylabel("drawdown %") | |
| 174 | + ax2.grid(alpha=0.25) | |
| 175 | + fig.tight_layout() | |
| 176 | + fig.savefig(a.plot, dpi=130) | |
| 177 | + print(f"wrote {a.plot}") | |
| 178 | + return 0 | |
| 179 | + | |
| 180 | + | |
| 181 | +if __name__ == "__main__": | |
| 182 | + sys.exit(main()) | |
added
skills/hfmd-term-structure/SKILL.md
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +--- | |
| 2 | +name: hfmd-term-structure | |
| 3 | +description: Fetch and analyse a futures term structure (curve) from HF Market Data — contango vs backwardation, spreads and annualised roll yield between contracts, curve shape changes between two dates, with a chart. Use when the user asks about the futures curve, contango/backwardation, calendar spreads, roll yield or "plot the CL/NG/VX term structure". | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# hfmd-term-structure | |
| 7 | + | |
| 8 | +The term structure is the set of prices of all listed contracts of one root on one day, ordered by expiry. | |
| 9 | +Its slope is the market's price for time: storage, financing, convenience yield and expected supply. | |
| 10 | + | |
| 11 | +## Vocabulary | |
| 12 | + | |
| 13 | +- **Contango**: later contracts priced *above* nearer ones (upward slope). Normal for storable commodities (storage + financing cost) and for VIX most of the time. A long rolling position pays the roll (negative roll yield). | |
| 14 | +- **Backwardation**: later contracts *below* nearer ones (downward slope). Signals tightness / high convenience yield (e.g. CL in 2022). Long rollers earn the roll. | |
| 15 | +- **Spread (M2 − M1)** in price and in %; **annualised roll yield** ≈ −(M2 − M1)/M1 × 365/days between expiries (positive = the roll pays you). | |
| 16 | +- Curves can be humped (NG winter premium, ZC harvest lows): describe by segment. | |
| 17 | + | |
| 18 | +## When to use | |
| 19 | + | |
| 20 | +- "Plot me the CL term structure", "is natural gas in contango?", "how did the curve change since January?", "what's the VX roll cost right now?" | |
| 21 | + | |
| 22 | +## Steps | |
| 23 | + | |
| 24 | +1. Get the curve: `python3 scripts/term_structure.py --root CL [--as-of 2025-09-01] [--compare 2025-01-02] --plot cl_curve.png` | |
| 25 | + - calls `/v1/futures/{root}/term-structure?as_of=` (v2). If the endpoint is not deployed (404) the script rebuilds the curve from `/v1/futures/{root}/contracts` + each contract's last close on `as_of` (one request per contract — API key recommended). | |
| 26 | + - prints: as-of date actually used, contracts with expiry / days-to-expiry / price / spread vs front (points and %) / annualised roll yield per leg / volume / OI, then the verdict (contango, backwardation or mixed with the segments), slope statistics (front–6th %, avg annualised carry), and — with `--compare` — the parallel shift, twist (front vs back change) and which legs moved most. | |
| 27 | +2. Interpret in the user's context (hedger vs long-only roller vs spread trader) using only the numbers printed. Note if a leg has no price (illiquid deferred months) — it is left blank, not interpolated. | |
| 28 | +3. Chart: price vs expiry (x = expiry date), both dates when `--compare`, front-month annotated. | |
| 29 | + | |
| 30 | +## Examples | |
| 31 | + | |
| 32 | +```bash | |
| 33 | +python3 scripts/term_structure.py --root CL --plot cl.png | |
| 34 | +python3 scripts/term_structure.py --root NG --as-of 2025-08-29 --compare 2025-02-28 --plot ng_vs.png | |
| 35 | +python3 scripts/term_structure.py --root VX --depth 8 --json vx.json # machine-readable | |
| 36 | +python3 scripts/term_structure.py --root ES --as-of 2024-06-14 # equity index: slope ≈ rates − dividends | |
| 37 | +``` | |
| 38 | + | |
| 39 | +## Gotchas | |
| 40 | + | |
| 41 | +- Quote the **as-of date the API used** (`meta.as_of`): asking for a weekend returns the previous session. | |
| 42 | +- Deferred contracts often have zero volume — their settlement is an exchange mark; say so when OI is tiny. | |
| 43 | +- Days-to-expiry uses the contract's `expiration_date` (rule-based or from data when the rule is unknown — `expiration_source` says which). | |
| 44 | +- Equity index curves (ES, NQ) are about financing minus dividends, not storage; VX is about the variance risk premium — do not talk about "inventories" there. | |
| 45 | +- Compare curves in **percent** of the front, not points, across dates when the price level moved a lot. | |
added
skills/hfmd-term-structure/scripts/hfmd.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only). | |
| 2 | + | |
| 3 | +Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py` | |
| 4 | +so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`. | |
| 5 | + | |
| 6 | +Environment: HFMD_API_KEY (optional, free account = 120 req/min; keyless = 30 req/h), | |
| 7 | + HFMD_BASE_URL (default https://www.hfmarketdata.io). | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import pandas as pd | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/") | |
| 20 | +API_KEY = os.environ.get("HFMD_API_KEY", "").strip() | |
| 21 | +UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)" | |
| 22 | + | |
| 23 | +TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day", | |
| 24 | + "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"} | |
| 25 | +V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class HfmdError(RuntimeError): | |
| 29 | + def __init__(self, status: int, code: str, message: str, url: str): | |
| 30 | + super().__init__(f"HF Market Data error {status} {code}: {message} ({url})") | |
| 31 | + self.status, self.code, self.url = status, code, url | |
| 32 | + | |
| 33 | + | |
| 34 | +_session = requests.Session() | |
| 35 | +_session.headers.update({"Accept": "application/json", "User-Agent": UA}) | |
| 36 | +if API_KEY: | |
| 37 | + _session.headers["Authorization"] = f"Bearer {API_KEY}" | |
| 38 | + | |
| 39 | + | |
| 40 | +def log(msg: str) -> None: | |
| 41 | + print(msg, file=sys.stderr) | |
| 42 | + | |
| 43 | + | |
| 44 | +def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]: | |
| 45 | + """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After.""" | |
| 46 | + url = f"{BASE_URL}{path}" | |
| 47 | + p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")} | |
| 48 | + p.setdefault("format", "json") | |
| 49 | + for attempt in range(retries + 1): | |
| 50 | + r = _session.get(url, params=p, timeout=120) | |
| 51 | + if r.status_code == 429 and attempt < retries: | |
| 52 | + wait = float(r.headers.get("Retry-After", "5")) | |
| 53 | + log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)") | |
| 54 | + time.sleep(min(wait, 120)) | |
| 55 | + continue | |
| 56 | + if r.status_code >= 400: | |
| 57 | + try: | |
| 58 | + body = r.json() | |
| 59 | + except ValueError: | |
| 60 | + body = {} | |
| 61 | + err = body.get("error") or {} | |
| 62 | + raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url) | |
| 63 | + rem = r.headers.get("X-RateLimit-Remaining-Requests") | |
| 64 | + if rem is not None: | |
| 65 | + log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left") | |
| 66 | + return r.json(), dict(r.headers) | |
| 67 | + raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url) | |
| 68 | + | |
| 69 | + | |
| 70 | +def rows_of(body: Any) -> list[dict]: | |
| 71 | + """Rows from either envelope: v1 {count,data} or v2 {data,meta}.""" | |
| 72 | + if isinstance(body, list): | |
| 73 | + return body | |
| 74 | + if isinstance(body, dict): | |
| 75 | + data = body.get("data") | |
| 76 | + if isinstance(data, list): | |
| 77 | + return data | |
| 78 | + return [] | |
| 79 | + | |
| 80 | + | |
| 81 | +def meta_of(body: Any) -> dict: | |
| 82 | + if isinstance(body, dict): | |
| 83 | + m = dict(body.get("meta") or {}) | |
| 84 | + if "count" in body and "count" not in m: | |
| 85 | + m["count"] = body["count"] | |
| 86 | + return m | |
| 87 | + return {} | |
| 88 | + | |
| 89 | + | |
| 90 | +def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame: | |
| 91 | + df = pd.DataFrame(rows_of(body)) | |
| 92 | + if time_col and time_col in df.columns: | |
| 93 | + df[time_col] = pd.to_datetime(df[time_col]) | |
| 94 | + df = df.sort_values(time_col).reset_index(drop=True) | |
| 95 | + return df | |
| 96 | + | |
| 97 | + | |
| 98 | +def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, | |
| 99 | + adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 100 | + """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed.""" | |
| 101 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 102 | + frames: list[pd.DataFrame] = [] | |
| 103 | + cursor_start = start | |
| 104 | + while True: | |
| 105 | + body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"}) | |
| 106 | + df = to_df(body) | |
| 107 | + if df.empty: | |
| 108 | + break | |
| 109 | + frames.append(df) | |
| 110 | + if len(df) < limit: | |
| 111 | + break | |
| 112 | + last = df["datetime"].iloc[-1] | |
| 113 | + cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d") | |
| 114 | + out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"]) | |
| 115 | + return out.drop_duplicates("datetime").reset_index(drop=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame: | |
| 119 | + """v2 individual futures contract bars (ESZ25…).""" | |
| 120 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 121 | + body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 122 | + return to_df(body) | |
| 123 | + | |
| 124 | + | |
| 125 | +def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day", | |
| 126 | + start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]: | |
| 127 | + """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls.""" | |
| 128 | + tf = TIMEFRAMES.get(timeframe, timeframe) | |
| 129 | + body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit}) | |
| 130 | + return to_df(body), meta_of(body) | |
| 131 | + | |
| 132 | + | |
| 133 | +def contracts(root: str, status: str | None = None) -> pd.DataFrame: | |
| 134 | + body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000}) | |
| 135 | + return to_df(body, time_col=None) | |
| 136 | + | |
| 137 | + | |
| 138 | +def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]: | |
| 139 | + body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of}) | |
| 140 | + return to_df(body, time_col=None), meta_of(body) | |
| 141 | + | |
| 142 | + | |
| 143 | +def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]: | |
| 144 | + body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit}) | |
| 145 | + return to_df(body, time_col=None), meta_of(body) | |
| 146 | + | |
| 147 | + | |
| 148 | +def ratios(ticker: str) -> dict: | |
| 149 | + body, _ = get(f"/v1/fundamentals/{ticker}/ratios") | |
| 150 | + d = body.get("data", body) if isinstance(body, dict) else body | |
| 151 | + return d if isinstance(d, dict) else (d[0] if d else {}) | |
| 152 | + | |
| 153 | + | |
| 154 | +def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame: | |
| 155 | + body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit}) | |
| 156 | + return to_df(body, time_col=None) | |
added
skills/hfmd-term-structure/scripts/term_structure.py
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Futures term structure: fetch, quantify (contango/backwardation, spreads, roll yield), compare, plot. | |
| 3 | + | |
| 4 | + term_structure.py --root CL [--as-of YYYY-MM-DD] [--compare YYYY-MM-DD] [--depth 12] [--plot out.png] [--json out.json] | |
| 5 | +""" | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import argparse | |
| 9 | +import json | |
| 10 | +import sys | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +import numpy as np | |
| 14 | +import pandas as pd | |
| 15 | + | |
| 16 | +sys.path.insert(0, str(Path(__file__).parent)) | |
| 17 | +import hfmd # noqa: E402 | |
| 18 | + | |
| 19 | +PRICE_COLS = ("settle", "close", "last", "price") | |
| 20 | + | |
| 21 | + | |
| 22 | +def price_col(df: pd.DataFrame) -> str | None: | |
| 23 | + return next((c for c in PRICE_COLS if c in df.columns), None) | |
| 24 | + | |
| 25 | + | |
| 26 | +def 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, meta | |
| 33 | + except hfmd.HfmdError as e: | |
| 34 | + if e.status != 404: | |
| 35 | + raise | |
| 36 | + 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 | + raise | |
| 43 | + 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 = [], None | |
| 49 | + 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 | + continue | |
| 54 | + 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"} | |
| 58 | + | |
| 59 | + | |
| 60 | +def 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.days | |
| 69 | + front = float(d[pc].dropna().iloc[0]) if d[pc].notna().any() else np.nan | |
| 70 | + d["spread_vs_front"] = d[pc] - front | |
| 71 | + d["spread_pct"] = d["spread_vs_front"] / front * 100 | |
| 72 | + 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 * 100 | |
| 75 | + 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, summary | |
| 97 | + | |
| 98 | + | |
| 99 | +def 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() | |
| 109 | + | |
| 110 | + df, meta = curve(root, a.as_of, a.depth) | |
| 111 | + if df.empty: | |
| 112 | + print("empty curve", file=sys.stderr) | |
| 113 | + return 1 | |
| 114 | + used = meta.get("as_of") or a.as_of | |
| 115 | + 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}") | |
| 125 | + | |
| 126 | + d2 = s2 = None | |
| 127 | + if a.compare: | |
| 128 | + df2, meta2 = curve(root, a.compare, a.depth) | |
| 129 | + used2 = meta2.get("as_of") or a.compare | |
| 130 | + 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) * 100 | |
| 137 | + print(" per-contract change (%):", ", ".join(f"{r.symbol} {r.chg_pct:+.1f}" for r in m.itertuples())) | |
| 138 | + | |
| 139 | + 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}") | |
| 143 | + | |
| 144 | + if a.plot: | |
| 145 | + try: | |
| 146 | + import matplotlib | |
| 147 | + matplotlib.use("Agg") | |
| 148 | + import matplotlib.pyplot as plt | |
| 149 | + except ImportError: | |
| 150 | + print("matplotlib not installed", file=sys.stderr) | |
| 151 | + return 0 | |
| 152 | + 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 0 | |
| 169 | + | |
| 170 | + | |
| 171 | +if __name__ == "__main__": | |
| 172 | + sys.exit(main()) | |
added
skills/scripts/build_skills.py
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Build the downloadable skills pack. | |
| 3 | + | |
| 4 | +1. Copies `skills/_shared/hfmd.py` into every `skills/hfmd-*/scripts/hfmd.py` (self-contained skills). | |
| 5 | +2. Zips the five skill folders (+ a top-level INSTALL.md) into | |
| 6 | + `hfmarketdata/web/public/downloads/hfmarketdata-skills.zip`, deterministically (fixed timestamps, | |
| 7 | + sorted entries) so re-running without changes yields an identical file. | |
| 8 | + | |
| 9 | +Usage: python3 skills/scripts/build_skills.py [--check] [--out PATH] | |
| 10 | +""" | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import argparse | |
| 14 | +import shutil | |
| 15 | +import sys | |
| 16 | +import zipfile | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +ROOT = Path(__file__).resolve().parents[2] | |
| 20 | +SKILLS = ROOT / "skills" | |
| 21 | +SHARED = SKILLS / "_shared" / "hfmd.py" | |
| 22 | +OUT = ROOT / "hfmarketdata" / "web" / "public" / "downloads" / "hfmarketdata-skills.zip" | |
| 23 | +FIXED_TIME = (2026, 1, 1, 0, 0, 0) | |
| 24 | + | |
| 25 | +INSTALL = """# HF Market Data skills — install | |
| 26 | + | |
| 27 | +Unzip into `~/.claude/skills/` (all projects) or `.claude/skills/` (this project only): | |
| 28 | + | |
| 29 | + unzip -o hfmarketdata-skills.zip -d ~/.claude/skills/ | |
| 30 | + | |
| 31 | +Requirements: Python 3.10+, `pip install requests pandas` (+ `matplotlib` for charts). | |
| 32 | +Optional: `export HFMD_API_KEY=hfmd_live_…` (free account → 120 requests/min; keyless → 30/h). | |
| 33 | + | |
| 34 | +Skills: hfmd-data-analysis · hfmd-quick-backtest · hfmd-continuous-futures · hfmd-fundamentals-screen · hfmd-term-structure | |
| 35 | +Docs: https://www.hfmarketdata.io/integrations/skills | |
| 36 | +""" | |
| 37 | + | |
| 38 | + | |
| 39 | +def skill_dirs() -> list[Path]: | |
| 40 | + return sorted(p for p in SKILLS.iterdir() if p.is_dir() and p.name.startswith("hfmd-") and (p / "SKILL.md").exists()) | |
| 41 | + | |
| 42 | + | |
| 43 | +def sync_shared(check: bool) -> int: | |
| 44 | + drift = 0 | |
| 45 | + src = SHARED.read_bytes() | |
| 46 | + for d in skill_dirs(): | |
| 47 | + dst = d / "scripts" / "hfmd.py" | |
| 48 | + if dst.exists() and dst.read_bytes() == src: | |
| 49 | + continue | |
| 50 | + if check: | |
| 51 | + print(f"DRIFT: {dst.relative_to(ROOT)} differs from _shared/hfmd.py", file=sys.stderr) | |
| 52 | + drift += 1 | |
| 53 | + else: | |
| 54 | + dst.parent.mkdir(parents=True, exist_ok=True) | |
| 55 | + shutil.copyfile(SHARED, dst) | |
| 56 | + print(f"synced {dst.relative_to(ROOT)}") | |
| 57 | + return drift | |
| 58 | + | |
| 59 | + | |
| 60 | +def build_zip(out: Path) -> None: | |
| 61 | + out.parent.mkdir(parents=True, exist_ok=True) | |
| 62 | + entries: list[tuple[str, bytes]] = [("INSTALL.md", INSTALL.encode())] | |
| 63 | + for d in skill_dirs(): | |
| 64 | + for f in sorted(d.rglob("*")): | |
| 65 | + if f.is_dir() or "__pycache__" in f.parts or f.suffix in {".pyc", ".png", ".csv"}: | |
| 66 | + continue | |
| 67 | + entries.append((str(f.relative_to(SKILLS)), f.read_bytes())) | |
| 68 | + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: | |
| 69 | + for name, data in entries: | |
| 70 | + info = zipfile.ZipInfo(name, date_time=FIXED_TIME) | |
| 71 | + info.compress_type = zipfile.ZIP_DEFLATED | |
| 72 | + info.external_attr = (0o755 if name.endswith(".py") else 0o644) << 16 | |
| 73 | + z.writestr(info, data) | |
| 74 | + print(f"wrote {out.relative_to(ROOT)} ({out.stat().st_size:,} bytes, {len(entries)} files, {len(skill_dirs())} skills)") | |
| 75 | + | |
| 76 | + | |
| 77 | +def main() -> int: | |
| 78 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 79 | + ap.add_argument("--check", action="store_true", help="only verify the copies of hfmd.py are in sync") | |
| 80 | + ap.add_argument("--out", type=Path, default=OUT) | |
| 81 | + a = ap.parse_args() | |
| 82 | + drift = sync_shared(a.check) | |
| 83 | + if a.check: | |
| 84 | + print("in sync" if not drift else f"{drift} file(s) out of sync") | |
| 85 | + return 1 if drift else 0 | |
| 86 | + build_zip(a.out) | |
| 87 | + return 0 | |
| 88 | + | |
| 89 | + | |
| 90 | +if __name__ == "__main__": | |
| 91 | + sys.exit(main()) | |
| 92 | ||