spb/hfmarketdata
Public
Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)
JavaScript 53.7%
Python 38.3%
CSS 4.6%
TypeScript 3.1%
1#!/usr/bin/env python32"""Summary statistics for a bars file produced by fetch_bars.py (one or several tickers).34 analyze.py aapl.csv [--plot aapl.png] [--periods-per-year 252]56Prints, per ticker: rows, range, CAGR, annualised volatility, Sharpe (rf=0), max drawdown (+ dates),7best/worst bar, skew, kurtosis, number of gaps > 3 calendar days, monthly returns table.8"""9from __future__ import annotations1011import argparse12import sys1314import numpy as np15import pandas as pd161718def infer_periods_per_year(dt: pd.Series) -> int:19 if len(dt) < 3:20 return 25221 step = dt.diff().dropna().median()22 if step >= pd.Timedelta(days=1):23 return 252 if dt.dt.dayofweek.max() <= 4 else 36524 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) * 252262728def 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() - 136 trough = dd.idxmin() if len(dd) else None37 peak = equity.loc[:trough].idxmax() if trough is not None else None38 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 out596061def 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.month65 return (t.pivot(index="year", columns="month", values="r") * 100).round(1)666768def 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()7475 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 df80 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())9091 if a.plot:92 try:93 import matplotlib94 matplotlib.use("Agg")95 import matplotlib.pyplot as plt96 except ImportError:97 print("matplotlib not installed: pip install matplotlib", file=sys.stderr)98 return 099 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 close104 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 0116117118if __name__ == "__main__":119 sys.exit(main())120