Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Examples

- **`examples/stock_anomalies.py`** — fetch a ticker's daily history from Yahoo
Finance and find its anomalous trading days (point / multivariate / collective),
or its distributional drift against another ticker (`--baseline`). A worked
example of consuming the `tq1` envelope: it parses the dense JSON contract and
maps each finding's handle back to a calendar date. Outside the Cargo workspace,
so it doesn't affect the build or gates.

## [1.1.0] - 2026-06-01

### Changed
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ crates/

Install: `cargo install anomalyx`.

## Examples

[`examples/stock_anomalies.py`](examples/README.md) fetches a stock's history
from Yahoo Finance and finds its anomalous trading days — or its distributional
drift against another ticker — as a worked example of consuming the `tq1`
envelope (handles mapped back to dates).

## Anomaly taxonomy

Seven classes, so an agent reasons about the *kind* of deviation:
Expand Down
37 changes: 37 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Examples

Worked examples of using anomalyx on real data. These live outside the Cargo
workspace (they shell out to the installed `anomalyx` binary), so they don't
affect the build or the gates.

## `stock_anomalies.py`

Fetches a stock's daily history from Yahoo Finance, enriches it with daily-return
and intraday-range columns, runs `anomalyx scan`, and prints the anomalous
trading days — mapping each finding's **handle back to a calendar date**. It's a
compact demonstration of *consuming the `tq1` contract*: it parses the dense JSON
envelope (the dictionary + dense finding rows), not pretty text.

```sh
pip install yfinance # one-time
cargo install anomalyx # or set $ANOMALYX to the binary path

# Anomalous trading days within one ticker (point / multivariate / collective):
python3 examples/stock_anomalies.py NVDA --period 2y

# Only the strongest, with false-discovery-rate control:
python3 examples/stock_anomalies.py NVDA --period 2y --fdr 0.01 --min-severity high

# Distributional drift of one ticker's behavior against another:
python3 examples/stock_anomalies.py NVDA --period 1y --baseline AMD
```

Any extra flags are passed straight through to `anomalyx scan` (e.g. `--top 20`,
`--no-column-roles`). The exit code mirrors anomalyx: `0` clean, `1` anomalies
found, `2` error.

On real NVDA history this surfaces, for example, the 2025‑01‑27 DeepSeek selloff
(top volume + the single largest multivariate outlier), the April‑2025 tariff
volatility, and the second‑half‑2025 price regime shift (`coll.cusum`) — and in
`--baseline` mode, that NVDA's volume and volatility *distributions* differ
sharply from a peer's.
154 changes: 154 additions & 0 deletions examples/stock_anomalies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
stock_anomalies.py — find a stock's anomalous trading days with anomalyx.

A worked example of *consuming the tq1 contract*. It fetches a ticker's daily
history from Yahoo Finance, enriches it with daily-return % and intraday-range %,
shells out to `anomalyx scan`, and then parses the dense JSON envelope —
the dictionary-pinned string table plus the dense finding rows — and maps each
finding's handle back to a calendar date. That handle-to-evidence walk is exactly
what an agent does with the output; the point is that the script reads a typed
contract, never pretty text.

Two modes:
* single corpus — point / multivariate / collective anomalies *within* one
ticker's series (volume spikes, big moves, regime shifts);
* `--baseline T` — distributional drift of one window/ticker against another
(the dist.ks / dist.psi detectors), e.g. "did volatility
regime-change?" or "how does NVDA differ from AMD?".

Usage:
pip install yfinance # one-time
cargo install anomalyx # or point $ANOMALYX at the binary
python3 examples/stock_anomalies.py NVDA --period 2y
python3 examples/stock_anomalies.py NVDA --period 2y --fdr 0.01 --min-severity high
python3 examples/stock_anomalies.py NVDA --period 1y --baseline AMD

Anything after the known flags is passed straight through to `anomalyx scan`
(e.g. `--top 20`, `--fdr 0.01`, `--no-column-roles`).

Requires: python3, yfinance, and the `anomalyx` binary on PATH (or `$ANOMALYX`).
Exit code mirrors anomalyx: 0 clean, 1 anomalies found, 2 error.
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile


def fetch(ticker: str, period: str):
"""Daily OHLCV + return%/range% for `ticker`, as a DataFrame (newest deps)."""
try:
import yfinance as yf
except ImportError:
sys.exit("yfinance is required: `pip install yfinance`")
df = yf.download(ticker, period=period, interval="1d", auto_adjust=True, progress=False)
if df is None or len(df) == 0:
sys.exit(f"no data returned for {ticker!r} (period={period})")
# yfinance may return a column MultiIndex for a single ticker; flatten it.
df.columns = [c[0] if isinstance(c, tuple) else c for c in df.columns]
df = df.reset_index()
df["daily_return_pct"] = (df["Close"].pct_change() * 100).round(4)
df["range_pct"] = ((df["High"] - df["Low"]) / df["Close"] * 100).round(4)
df = df.dropna().reset_index(drop=True)
df["Date"] = df["Date"].astype(str)
return df


def anomalyx_scan(csv_path: str, extra_args: list[str]) -> dict:
"""Run `anomalyx scan` and parse the tq1 envelope. Exits on a tool error."""
exe = os.environ.get("ANOMALYX", "anomalyx")
if shutil.which(exe) is None and not os.path.exists(exe):
sys.exit(f"`{exe}` not found — run `cargo install anomalyx` or set $ANOMALYX")
proc = subprocess.run(
[exe, "scan", *extra_args, csv_path], capture_output=True, text=True
)
if proc.returncode == 2: # committed: 0 clean, 1 anomalies, 2 tool error
sys.exit(f"anomalyx error: {proc.stderr.strip()}")
return json.loads(proc.stdout)
Comment on lines +71 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tool-error exit code is not mirrored (returns 1 instead of 2).

sys.exit("...") exits with status code 1, so an anomalyx tool error (returncode == 2) is currently collapsed to 1. That breaks the script’s stated 0/1/2 contract.

Proposed fix
-    if proc.returncode == 2:  # committed: 0 clean, 1 anomalies, 2 tool error
-        sys.exit(f"anomalyx error: {proc.stderr.strip()}")
+    if proc.returncode == 2:  # committed: 0 clean, 1 anomalies, 2 tool error
+        print(f"anomalyx error: {proc.stderr.strip()}", file=sys.stderr)
+        sys.exit(2)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if proc.returncode == 2: # committed: 0 clean, 1 anomalies, 2 tool error
sys.exit(f"anomalyx error: {proc.stderr.strip()}")
return json.loads(proc.stdout)
if proc.returncode == 2: # committed: 0 clean, 1 anomalies, 2 tool error
print(f"anomalyx error: {proc.stderr.strip()}", file=sys.stderr)
sys.exit(2)
return json.loads(proc.stdout)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stock_anomalies.py` around lines 71 - 73, The current branch that
handles proc.returncode == 2 calls sys.exit with a string which yields exit code
1; change it to emit the error message to stderr (e.g., write or print to
sys.stderr) and then call sys.exit(2) so the process actually exits with code 2;
locate the check on proc.returncode in examples/stock_anomalies.py (the block
that currently does sys.exit(f"anomalyx error: {proc.stderr.strip()}")) and
replace that single sys.exit(...) call with stderr output followed by
sys.exit(2).



def describe_handle(handle: str, dates: list[str]) -> str:
"""Map a finding handle back to a human-readable 'when/what'."""
parts = handle.split(":")
kind = parts[0]
if kind == "cell": # cell:COLUMN:row
return f"{dates[int(parts[2])]} {parts[1]}"
if kind == "row": # row:index (multivariate — a whole day)
return f"{dates[int(parts[1])]} (all columns)"
if kind == "range": # range:COLUMN:start:end (collective level shift)
a, b = int(parts[2]), min(int(parts[3]), len(dates) - 1)
return f"{parts[1]} {dates[a]} -> {dates[b]}"
if kind == "dist": # dist:COLUMN (distributional drift vs baseline)
return f"{parts[1]} (distribution)"
return handle


def report(env: dict, dates: list[str]) -> None:
dic = env["dict"]
summ = env["summary"]
print(
f"format={env['format']} rows={env['rows_scanned']} "
f"exit={env['exit']} detected={summ['total']} max_severity={summ.get('max_severity')}"
)
print("roles: " + ", ".join(f"{c['column']}={c['role']}" for c in env.get("roles", [])))
if scope := env.get("scope"):
print(f"scope: emitted {scope['emitted']} of {scope['detected']} (dropped {scope['dropped']})")
print()
# `rows` is already sorted severity-first by anomalyx; just walk it.
for row in env["rows"]:
detector, severity = dic[row[0]], dic[row[4]]
when = describe_handle(dic[row[2]], dates)
reason = dic[row[6]]
print(f" [{severity:>8}] {detector:<15} {when}")
print(f" {reason}")
if not env["rows"]:
print(" (no findings)")


def main() -> None:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("ticker", nargs="?", default="NVDA")
ap.add_argument("--period", default="2y", help="yfinance period (1y, 2y, 5y, max, …)")
ap.add_argument(
"--baseline",
metavar="TICKER",
help="compare against another ticker for distributional drift",
)
ap.add_argument(
"--baseline-period", help="period for the baseline ticker (default: --period)"
)
args, scan_args = ap.parse_known_args()

tmp = tempfile.mkdtemp(prefix="anomalyx-stock-")
df = fetch(args.ticker, args.period)
cur_csv = os.path.join(tmp, f"{args.ticker}.csv")
df.to_csv(cur_csv, index=False)
dates = df["Date"].tolist()

extra = list(scan_args)
if args.baseline:
bdf = fetch(args.baseline, args.baseline_period or args.period)
base_csv = os.path.join(tmp, f"{args.baseline}.csv")
bdf.to_csv(base_csv, index=False)
# Compare the *behavioral* distributions (volume / return / volatility);
# excluding price levels and the Date label keeps drift meaningful.
extra = ["--baseline", base_csv, "--columns", "daily_return_pct,range_pct,Volume", *extra]
print(f"# {args.ticker} ({args.period}) vs baseline {args.baseline} — distributional drift\n")
else:
print(f"# {args.ticker} ({args.period}) — anomalous trading days\n")

env = anomalyx_scan(cur_csv, extra)
report(env, dates)
sys.exit(0 if env["exit"] == 0 else 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Final process exit should mirror env["exit"] directly.

This currently coerces every non-zero to 1. If the envelope exit ever carries 2, that gets lost.

Proposed fix
-    sys.exit(0 if env["exit"] == 0 else 1)
+    sys.exit(int(env["exit"]))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sys.exit(0 if env["exit"] == 0 else 1)
sys.exit(int(env["exit"]))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stock_anomalies.py` at line 150, The final process exit currently
coerces env["exit"] to 0 or 1; change the call that uses sys.exit to pass the
raw envelope exit code (env["exit"]) directly so non-zero codes like 2 are
preserved—locate the sys.exit(...) invocation referencing env["exit"] and
replace the coercion logic with a direct sys.exit(env["exit"]) behavior.



if __name__ == "__main__":
main()