Skip to content

Commit eaa1000

Browse files
copyleftdevclaude
andauthored
docs: add examples/stock_anomalies.py — fetch + scan worked example (#64)
A committed, runnable example of using anomalyx on real data and consuming the tq1 contract. Fetches a ticker's daily history from Yahoo Finance (yfinance), enriches with daily-return% and intraday-range%, runs `anomalyx scan`, parses the dense JSON envelope (dict + dense rows), and maps each finding's handle back to a calendar date. Two modes: single-corpus (point/mv/collective anomalies within a series) and --baseline TICKER (distributional drift of one ticker's behavior vs another, exercising dist.ks/psi). Extra args pass through to `anomalyx scan` (--fdr, --top, --min-severity, …); exit code mirrors anomalyx. Lives outside the Cargo workspace (shells out to the installed binary), so it doesn't touch the build or gates. + examples/README.md and a README pointer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b173338 commit eaa1000

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Examples
10+
11+
- **`examples/stock_anomalies.py`** — fetch a ticker's daily history from Yahoo
12+
Finance and find its anomalous trading days (point / multivariate / collective),
13+
or its distributional drift against another ticker (`--baseline`). A worked
14+
example of consuming the `tq1` envelope: it parses the dense JSON contract and
15+
maps each finding's handle back to a calendar date. Outside the Cargo workspace,
16+
so it doesn't affect the build or gates.
17+
918
## [1.1.0] - 2026-06-01
1019

1120
### Changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ crates/
114114

115115
Install: `cargo install anomalyx`.
116116

117+
## Examples
118+
119+
[`examples/stock_anomalies.py`](examples/README.md) fetches a stock's history
120+
from Yahoo Finance and finds its anomalous trading days — or its distributional
121+
drift against another ticker — as a worked example of consuming the `tq1`
122+
envelope (handles mapped back to dates).
123+
117124
## Anomaly taxonomy
118125

119126
Seven classes, so an agent reasons about the *kind* of deviation:

examples/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Examples
2+
3+
Worked examples of using anomalyx on real data. These live outside the Cargo
4+
workspace (they shell out to the installed `anomalyx` binary), so they don't
5+
affect the build or the gates.
6+
7+
## `stock_anomalies.py`
8+
9+
Fetches a stock's daily history from Yahoo Finance, enriches it with daily-return
10+
and intraday-range columns, runs `anomalyx scan`, and prints the anomalous
11+
trading days — mapping each finding's **handle back to a calendar date**. It's a
12+
compact demonstration of *consuming the `tq1` contract*: it parses the dense JSON
13+
envelope (the dictionary + dense finding rows), not pretty text.
14+
15+
```sh
16+
pip install yfinance # one-time
17+
cargo install anomalyx # or set $ANOMALYX to the binary path
18+
19+
# Anomalous trading days within one ticker (point / multivariate / collective):
20+
python3 examples/stock_anomalies.py NVDA --period 2y
21+
22+
# Only the strongest, with false-discovery-rate control:
23+
python3 examples/stock_anomalies.py NVDA --period 2y --fdr 0.01 --min-severity high
24+
25+
# Distributional drift of one ticker's behavior against another:
26+
python3 examples/stock_anomalies.py NVDA --period 1y --baseline AMD
27+
```
28+
29+
Any extra flags are passed straight through to `anomalyx scan` (e.g. `--top 20`,
30+
`--no-column-roles`). The exit code mirrors anomalyx: `0` clean, `1` anomalies
31+
found, `2` error.
32+
33+
On real NVDA history this surfaces, for example, the 2025‑01‑27 DeepSeek selloff
34+
(top volume + the single largest multivariate outlier), the April‑2025 tariff
35+
volatility, and the second‑half‑2025 price regime shift (`coll.cusum`) — and in
36+
`--baseline` mode, that NVDA's volume and volatility *distributions* differ
37+
sharply from a peer's.

examples/stock_anomalies.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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

Comments
 (0)