|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +stock_anomalies.py — find a stock's anomalous trading days with anomalyx. |
| 4 | +
|
| 5 | +A worked example of *consuming the tq1 contract*. It fetches a ticker's daily |
| 6 | +history from Yahoo Finance, enriches it with daily-return % and intraday-range %, |
| 7 | +shells out to `anomalyx scan`, and then parses the dense JSON envelope — |
| 8 | +the dictionary-pinned string table plus the dense finding rows — and maps each |
| 9 | +finding's handle back to a calendar date. That handle-to-evidence walk is exactly |
| 10 | +what an agent does with the output; the point is that the script reads a typed |
| 11 | +contract, never pretty text. |
| 12 | +
|
| 13 | +Two modes: |
| 14 | + * single corpus — point / multivariate / collective anomalies *within* one |
| 15 | + ticker's series (volume spikes, big moves, regime shifts); |
| 16 | + * `--baseline T` — distributional drift of one window/ticker against another |
| 17 | + (the dist.ks / dist.psi detectors), e.g. "did volatility |
| 18 | + regime-change?" or "how does NVDA differ from AMD?". |
| 19 | +
|
| 20 | +Usage: |
| 21 | + pip install yfinance # one-time |
| 22 | + cargo install anomalyx # or point $ANOMALYX at the binary |
| 23 | + python3 examples/stock_anomalies.py NVDA --period 2y |
| 24 | + python3 examples/stock_anomalies.py NVDA --period 2y --fdr 0.01 --min-severity high |
| 25 | + python3 examples/stock_anomalies.py NVDA --period 1y --baseline AMD |
| 26 | +
|
| 27 | +Anything after the known flags is passed straight through to `anomalyx scan` |
| 28 | +(e.g. `--top 20`, `--fdr 0.01`, `--no-column-roles`). |
| 29 | +
|
| 30 | +Requires: python3, yfinance, and the `anomalyx` binary on PATH (or `$ANOMALYX`). |
| 31 | +Exit code mirrors anomalyx: 0 clean, 1 anomalies found, 2 error. |
| 32 | +""" |
| 33 | +from __future__ import annotations |
| 34 | + |
| 35 | +import argparse |
| 36 | +import json |
| 37 | +import os |
| 38 | +import shutil |
| 39 | +import subprocess |
| 40 | +import sys |
| 41 | +import tempfile |
| 42 | + |
| 43 | + |
| 44 | +def fetch(ticker: str, period: str): |
| 45 | + """Daily OHLCV + return%/range% for `ticker`, as a DataFrame (newest deps).""" |
| 46 | + try: |
| 47 | + import yfinance as yf |
| 48 | + except ImportError: |
| 49 | + sys.exit("yfinance is required: `pip install yfinance`") |
| 50 | + df = yf.download(ticker, period=period, interval="1d", auto_adjust=True, progress=False) |
| 51 | + if df is None or len(df) == 0: |
| 52 | + sys.exit(f"no data returned for {ticker!r} (period={period})") |
| 53 | + # yfinance may return a column MultiIndex for a single ticker; flatten it. |
| 54 | + df.columns = [c[0] if isinstance(c, tuple) else c for c in df.columns] |
| 55 | + df = df.reset_index() |
| 56 | + df["daily_return_pct"] = (df["Close"].pct_change() * 100).round(4) |
| 57 | + df["range_pct"] = ((df["High"] - df["Low"]) / df["Close"] * 100).round(4) |
| 58 | + df = df.dropna().reset_index(drop=True) |
| 59 | + df["Date"] = df["Date"].astype(str) |
| 60 | + return df |
| 61 | + |
| 62 | + |
| 63 | +def anomalyx_scan(csv_path: str, extra_args: list[str]) -> dict: |
| 64 | + """Run `anomalyx scan` and parse the tq1 envelope. Exits on a tool error.""" |
| 65 | + exe = os.environ.get("ANOMALYX", "anomalyx") |
| 66 | + if shutil.which(exe) is None and not os.path.exists(exe): |
| 67 | + sys.exit(f"`{exe}` not found — run `cargo install anomalyx` or set $ANOMALYX") |
| 68 | + proc = subprocess.run( |
| 69 | + [exe, "scan", *extra_args, csv_path], capture_output=True, text=True |
| 70 | + ) |
| 71 | + if proc.returncode == 2: # committed: 0 clean, 1 anomalies, 2 tool error |
| 72 | + sys.exit(f"anomalyx error: {proc.stderr.strip()}") |
| 73 | + return json.loads(proc.stdout) |
| 74 | + |
| 75 | + |
| 76 | +def describe_handle(handle: str, dates: list[str]) -> str: |
| 77 | + """Map a finding handle back to a human-readable 'when/what'.""" |
| 78 | + parts = handle.split(":") |
| 79 | + kind = parts[0] |
| 80 | + if kind == "cell": # cell:COLUMN:row |
| 81 | + return f"{dates[int(parts[2])]} {parts[1]}" |
| 82 | + if kind == "row": # row:index (multivariate — a whole day) |
| 83 | + return f"{dates[int(parts[1])]} (all columns)" |
| 84 | + if kind == "range": # range:COLUMN:start:end (collective level shift) |
| 85 | + a, b = int(parts[2]), min(int(parts[3]), len(dates) - 1) |
| 86 | + return f"{parts[1]} {dates[a]} -> {dates[b]}" |
| 87 | + if kind == "dist": # dist:COLUMN (distributional drift vs baseline) |
| 88 | + return f"{parts[1]} (distribution)" |
| 89 | + return handle |
| 90 | + |
| 91 | + |
| 92 | +def report(env: dict, dates: list[str]) -> None: |
| 93 | + dic = env["dict"] |
| 94 | + summ = env["summary"] |
| 95 | + print( |
| 96 | + f"format={env['format']} rows={env['rows_scanned']} " |
| 97 | + f"exit={env['exit']} detected={summ['total']} max_severity={summ.get('max_severity')}" |
| 98 | + ) |
| 99 | + print("roles: " + ", ".join(f"{c['column']}={c['role']}" for c in env.get("roles", []))) |
| 100 | + if scope := env.get("scope"): |
| 101 | + print(f"scope: emitted {scope['emitted']} of {scope['detected']} (dropped {scope['dropped']})") |
| 102 | + print() |
| 103 | + # `rows` is already sorted severity-first by anomalyx; just walk it. |
| 104 | + for row in env["rows"]: |
| 105 | + detector, severity = dic[row[0]], dic[row[4]] |
| 106 | + when = describe_handle(dic[row[2]], dates) |
| 107 | + reason = dic[row[6]] |
| 108 | + print(f" [{severity:>8}] {detector:<15} {when}") |
| 109 | + print(f" {reason}") |
| 110 | + if not env["rows"]: |
| 111 | + print(" (no findings)") |
| 112 | + |
| 113 | + |
| 114 | +def main() -> None: |
| 115 | + ap = argparse.ArgumentParser( |
| 116 | + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 117 | + ) |
| 118 | + ap.add_argument("ticker", nargs="?", default="NVDA") |
| 119 | + ap.add_argument("--period", default="2y", help="yfinance period (1y, 2y, 5y, max, …)") |
| 120 | + ap.add_argument( |
| 121 | + "--baseline", |
| 122 | + metavar="TICKER", |
| 123 | + help="compare against another ticker for distributional drift", |
| 124 | + ) |
| 125 | + ap.add_argument( |
| 126 | + "--baseline-period", help="period for the baseline ticker (default: --period)" |
| 127 | + ) |
| 128 | + args, scan_args = ap.parse_known_args() |
| 129 | + |
| 130 | + tmp = tempfile.mkdtemp(prefix="anomalyx-stock-") |
| 131 | + df = fetch(args.ticker, args.period) |
| 132 | + cur_csv = os.path.join(tmp, f"{args.ticker}.csv") |
| 133 | + df.to_csv(cur_csv, index=False) |
| 134 | + dates = df["Date"].tolist() |
| 135 | + |
| 136 | + extra = list(scan_args) |
| 137 | + if args.baseline: |
| 138 | + bdf = fetch(args.baseline, args.baseline_period or args.period) |
| 139 | + base_csv = os.path.join(tmp, f"{args.baseline}.csv") |
| 140 | + bdf.to_csv(base_csv, index=False) |
| 141 | + # Compare the *behavioral* distributions (volume / return / volatility); |
| 142 | + # excluding price levels and the Date label keeps drift meaningful. |
| 143 | + extra = ["--baseline", base_csv, "--columns", "daily_return_pct,range_pct,Volume", *extra] |
| 144 | + print(f"# {args.ticker} ({args.period}) vs baseline {args.baseline} — distributional drift\n") |
| 145 | + else: |
| 146 | + print(f"# {args.ticker} ({args.period}) — anomalous trading days\n") |
| 147 | + |
| 148 | + env = anomalyx_scan(cur_csv, extra) |
| 149 | + report(env, dates) |
| 150 | + sys.exit(0 if env["exit"] == 0 else 1) |
| 151 | + |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + main() |
0 commit comments