optionctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
optionctl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """The `optionctl` APIs."""
2
+
3
+ import importlib.metadata
4
+
5
+ __version__ = importlib.metadata.version("optionctl")
optionctl/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """The `python -m optionctl` entrypoint."""
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ from optionctl._cli import main
5
+
6
+ main()
optionctl/_cli.py ADDED
@@ -0,0 +1,451 @@
1
+ """The optionctl CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import sys
8
+ from collections.abc import Callable
9
+ from importlib.metadata import version as package_version
10
+ from typing import TYPE_CHECKING, TypeVar
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from optionctl.models import ScoringWeights
17
+ from optionctl.predictions import _BRIER_EXCELLENT, _BRIER_GOOD
18
+
19
+ if TYPE_CHECKING:
20
+ from optionctl.models import OptionCandidate, ScanResult
21
+
22
+ console = Console(stderr=True)
23
+ output_console = Console()
24
+
25
+ _T = TypeVar("_T")
26
+ _ProgressCallback = Callable[[str, int, int], None]
27
+
28
+ _DEFAULT_LIMIT = 20
29
+
30
+ _DEFAULT_MIN_DTE = 0
31
+ _DEFAULT_MAX_DTE = 15
32
+ _DEFAULT_MAX_PRICE = 0.05
33
+ _DEFAULT_MIN_VOLUME = 250
34
+ _DEFAULT_MIN_VOL_OI = 1.0
35
+
36
+ # Weighting tuned for unusual flow (new opening activity over legacy OI).
37
+ _UNUSUAL_WEIGHTS = ScoringWeights(
38
+ vol_oi=55.0,
39
+ volume=25.0,
40
+ proximity=15.0,
41
+ iv=5.0,
42
+ earnings=0.0,
43
+ vol_vs_avg=0.0,
44
+ )
45
+
46
+ # Proximity thresholds for table color coding.
47
+ _PROXIMITY_GOOD = 10
48
+ _PROXIMITY_MODERATE = 20
49
+
50
+
51
+ def _render_table(candidates: list[OptionCandidate], title: str) -> None:
52
+ """Render candidates as a rich table."""
53
+ table = Table(title=title, show_lines=False)
54
+ table.add_column("Ticker", style="cyan")
55
+ table.add_column("Strike", justify="right")
56
+ table.add_column("Exp", style="green")
57
+ table.add_column("Ask", justify="right", style="yellow")
58
+ table.add_column("Vol", justify="right")
59
+ table.add_column("OI", justify="right")
60
+ table.add_column("Vol/OI", justify="right", style="magenta")
61
+ table.add_column("IV", justify="right")
62
+ table.add_column("Dist%", justify="right")
63
+ table.add_column("P(ITM)", justify="right")
64
+ table.add_column("Score", justify="right", style="bold green")
65
+
66
+ for c in candidates:
67
+ if c.proximity_pct < _PROXIMITY_GOOD:
68
+ dist_str = f"[bold green]{c.proximity_pct:.1f}%[/bold green]"
69
+ elif c.proximity_pct < _PROXIMITY_MODERATE:
70
+ dist_str = f"[yellow]{c.proximity_pct:.1f}%[/yellow]"
71
+ else:
72
+ dist_str = f"[red]{c.proximity_pct:.1f}%[/red]"
73
+
74
+ table.add_row(
75
+ c.ticker,
76
+ f"{c.strike:.2f}",
77
+ c.expiration.isoformat(),
78
+ f"{c.ask:.2f}",
79
+ f"{c.volume:,}",
80
+ f"{c.open_interest:,}",
81
+ f"{c.volume_oi_ratio:.1f}",
82
+ f"{c.implied_volatility:.0%}",
83
+ dist_str,
84
+ f"{c.p_itm:.1%}",
85
+ f"{c.score:.1f}",
86
+ )
87
+
88
+ output_console.print(table)
89
+
90
+
91
+ def _print_scan_summary(result: ScanResult) -> None:
92
+ """Print a standard scan summary line."""
93
+ console.print(
94
+ f"Scanned {result.tickers_scanned} tickers, "
95
+ f"{result.tickers_with_options} had options, "
96
+ f"found {len(result.candidates)} unusual contracts",
97
+ )
98
+
99
+
100
+ def _render_json(candidates: list[OptionCandidate]) -> None:
101
+ """Render candidates as JSON."""
102
+ data = [
103
+ {
104
+ "ticker": c.ticker,
105
+ "strike": c.strike,
106
+ "expiration": c.expiration.isoformat(),
107
+ "ask": c.ask,
108
+ "bid": c.bid,
109
+ "volume": c.volume,
110
+ "open_interest": c.open_interest,
111
+ "volume_oi_ratio": round(c.volume_oi_ratio, 2),
112
+ "implied_volatility": round(c.implied_volatility, 4),
113
+ "proximity_pct": round(c.proximity_pct, 2),
114
+ "dte": c.dte,
115
+ "score": round(c.score, 1),
116
+ "p_itm": round(c.p_itm, 4) if c.p_itm is not None else None,
117
+ "contract_symbol": c.contract_symbol,
118
+ }
119
+ for c in candidates
120
+ ]
121
+ print(json.dumps(data, indent=2))
122
+
123
+
124
+ def _render_csv(candidates: list[OptionCandidate]) -> None:
125
+ """Render candidates as CSV."""
126
+ writer = csv.writer(sys.stdout)
127
+ writer.writerow(
128
+ [
129
+ "ticker",
130
+ "strike",
131
+ "expiration",
132
+ "ask",
133
+ "bid",
134
+ "volume",
135
+ "open_interest",
136
+ "volume_oi_ratio",
137
+ "implied_volatility",
138
+ "proximity_pct",
139
+ "dte",
140
+ "score",
141
+ "contract_symbol",
142
+ ]
143
+ )
144
+ for c in candidates:
145
+ writer.writerow(
146
+ [
147
+ c.ticker,
148
+ c.strike,
149
+ c.expiration.isoformat(),
150
+ c.ask,
151
+ c.bid,
152
+ c.volume,
153
+ c.open_interest,
154
+ round(c.volume_oi_ratio, 2),
155
+ round(c.implied_volatility, 4),
156
+ round(c.proximity_pct, 2),
157
+ c.dte,
158
+ round(c.score, 1),
159
+ c.contract_symbol,
160
+ ]
161
+ )
162
+
163
+
164
+ def _render(
165
+ candidates: list[OptionCandidate],
166
+ output: str,
167
+ title: str,
168
+ limit: int = _DEFAULT_LIMIT,
169
+ ) -> None:
170
+ """Dispatch rendering to the appropriate format."""
171
+ if not candidates and output == "table":
172
+ console.print("[yellow]No unusual options found.[/yellow]")
173
+ return
174
+
175
+ total = len(candidates)
176
+ shown = candidates[:limit] if limit > 0 else candidates
177
+
178
+ if output == "json":
179
+ _render_json(shown)
180
+ elif output == "csv":
181
+ _render_csv(shown)
182
+ else:
183
+ _render_table(shown, title)
184
+
185
+ if limit > 0 and total > limit:
186
+ console.print(f"Showing {limit} of {total} contracts (use --all to see all)")
187
+
188
+
189
+ def _run_with_progress(
190
+ *,
191
+ total: int,
192
+ task_label: str,
193
+ description_prefix: str,
194
+ runner: Callable[[_ProgressCallback], _T],
195
+ ) -> _T:
196
+ """Run a ticker-processing function with a shared progress bar."""
197
+ from rich.progress import Progress
198
+
199
+ with Progress(console=console) as progress:
200
+ task = progress.add_task(task_label, total=total)
201
+
202
+ def _on_progress(ticker: str, current: int, _total: int) -> None:
203
+ progress.update(
204
+ task, completed=current, description=f"{description_prefix} {ticker}..."
205
+ )
206
+
207
+ return runner(_on_progress)
208
+
209
+
210
+ @click.group()
211
+ @click.version_option(package_name="optionctl")
212
+ def main() -> None:
213
+ """Unusual options flow scanner for S&P 500 tickers."""
214
+
215
+
216
+ @main.command()
217
+ def version() -> None:
218
+ """Print version information."""
219
+ click.echo(f"optionctl, version {package_version('optionctl')}")
220
+
221
+
222
+ @main.command()
223
+ @click.option(
224
+ "--min-dte",
225
+ type=int,
226
+ default=_DEFAULT_MIN_DTE,
227
+ show_default=True,
228
+ help="Minimum days to expiration.",
229
+ )
230
+ @click.option(
231
+ "--max-dte",
232
+ type=int,
233
+ default=_DEFAULT_MAX_DTE,
234
+ show_default=True,
235
+ help="Maximum days to expiration.",
236
+ )
237
+ @click.option(
238
+ "--max-price",
239
+ type=float,
240
+ default=_DEFAULT_MAX_PRICE,
241
+ show_default=True,
242
+ help="Maximum ask/last price per contract.",
243
+ )
244
+ @click.option(
245
+ "--min-volume",
246
+ type=int,
247
+ default=_DEFAULT_MIN_VOLUME,
248
+ show_default=True,
249
+ help="Minimum contract volume.",
250
+ )
251
+ @click.option(
252
+ "--min-vol-oi",
253
+ type=float,
254
+ default=_DEFAULT_MIN_VOL_OI,
255
+ show_default=True,
256
+ help="Minimum volume/open-interest ratio.",
257
+ )
258
+ @click.option(
259
+ "--refresh",
260
+ is_flag=True,
261
+ default=False,
262
+ help="Bypass cache and fetch fresh data.",
263
+ )
264
+ @click.option(
265
+ "--output",
266
+ "output_fmt",
267
+ type=click.Choice(["table", "json", "csv"]),
268
+ default="table",
269
+ show_default=True,
270
+ help="Output format.",
271
+ )
272
+ @click.option(
273
+ "--limit",
274
+ type=int,
275
+ default=_DEFAULT_LIMIT,
276
+ show_default=True,
277
+ help="Maximum contracts to display.",
278
+ )
279
+ @click.option(
280
+ "--all",
281
+ "show_all",
282
+ is_flag=True,
283
+ default=False,
284
+ help="Show all matching contracts.",
285
+ )
286
+ @click.option(
287
+ "--ticker",
288
+ "tickers_input",
289
+ multiple=True,
290
+ help="Specific ticker(s) to scan (repeatable). Defaults to S&P 500.",
291
+ )
292
+ def scan(
293
+ min_dte: int,
294
+ max_dte: int,
295
+ max_price: float,
296
+ min_volume: int,
297
+ min_vol_oi: float,
298
+ refresh: bool,
299
+ output_fmt: str,
300
+ limit: int,
301
+ show_all: bool,
302
+ tickers_input: tuple[str, ...],
303
+ ) -> None:
304
+ """Scan for unusual options activity."""
305
+ from optionctl.scanner import scan_universe
306
+
307
+ if tickers_input:
308
+ tickers = [t.upper() for t in tickers_input]
309
+ label = ", ".join(tickers)
310
+ title = f"Unusual Options Flow ({label})"
311
+ else:
312
+ from optionctl.universe import get_sp500_tickers
313
+
314
+ tickers = get_sp500_tickers(use_cache=not refresh)
315
+ label = "S&P 500"
316
+ title = "Unusual Options Flow (S&P 500)"
317
+
318
+ console.print(f"Scanning {len(tickers)} {label} tickers for unusual flow...")
319
+
320
+ result = _run_with_progress(
321
+ total=len(tickers),
322
+ task_label="Scanning...",
323
+ description_prefix="Scanning",
324
+ runner=lambda on_progress: scan_universe(
325
+ tickers,
326
+ min_dte=min_dte,
327
+ max_dte=max_dte,
328
+ max_price=max_price,
329
+ min_volume=min_volume,
330
+ min_vol_oi=min_vol_oi,
331
+ progress_callback=on_progress,
332
+ weights=_UNUSUAL_WEIGHTS,
333
+ use_cache=not refresh,
334
+ ),
335
+ )
336
+ _print_scan_summary(result)
337
+ _render(
338
+ result.candidates,
339
+ output_fmt,
340
+ title,
341
+ limit=0 if show_all else limit,
342
+ )
343
+
344
+
345
+ @main.group()
346
+ def cache() -> None:
347
+ """Manage option chain cache."""
348
+
349
+
350
+ @cache.command()
351
+ @click.option(
352
+ "--all",
353
+ "fetch_all",
354
+ is_flag=True,
355
+ default=False,
356
+ help="Cache all expirations (not just near-term).",
357
+ )
358
+ def warm(fetch_all: bool) -> None:
359
+ """Pre-fetch option chains for the S&P 500 universe."""
360
+ from optionctl.scanner import warm_cache
361
+ from optionctl.universe import get_sp500_tickers
362
+
363
+ tickers = get_sp500_tickers()
364
+ console.print(f"Warming cache for {len(tickers)} tickers...")
365
+
366
+ cached = _run_with_progress(
367
+ total=len(tickers),
368
+ task_label="Fetching...",
369
+ description_prefix="Fetching",
370
+ runner=lambda on_progress: warm_cache(
371
+ tickers,
372
+ progress_callback=on_progress,
373
+ max_dte=0 if fetch_all else _DEFAULT_MAX_DTE,
374
+ ),
375
+ )
376
+
377
+ console.print(f"Cached {cached}/{len(tickers)} tickers")
378
+
379
+
380
+ @cache.command()
381
+ def clear() -> None:
382
+ """Clear all cached option chain data."""
383
+ from optionctl.cache import clear_cache
384
+
385
+ count = clear_cache()
386
+ console.print(f"Cleared {count} cached files")
387
+
388
+
389
+ @cache.command()
390
+ def status() -> None:
391
+ """Show cache statistics."""
392
+ from optionctl.cache import get_cache_stats
393
+
394
+ stats = get_cache_stats()
395
+ console.print(f"Cached tickers: {stats['count']}")
396
+ console.print(f"Cache size: {stats['size_mb']} MB")
397
+ if stats["count"] > 0 and stats["count"] <= 20: # noqa: PLR2004
398
+ console.print(f"Tickers: {', '.join(stats['tickers'])}")
399
+
400
+
401
+ @main.command()
402
+ @click.option(
403
+ "--days",
404
+ type=int,
405
+ default=30,
406
+ show_default=True,
407
+ help="Number of days to include in calibration window.",
408
+ )
409
+ def calibration(days: int) -> None:
410
+ """Show Brier score calibration for p_itm predictions."""
411
+ from optionctl.predictions import get_calibration_summary, resolve_outcomes
412
+
413
+ console.print("Resolving expired predictions...")
414
+ resolved = resolve_outcomes()
415
+ if resolved:
416
+ console.print(f"Resolved {resolved} predictions")
417
+
418
+ summary = get_calibration_summary(days)
419
+
420
+ table = Table(title=f"Calibration Summary (last {days} days)", show_lines=False)
421
+ table.add_column("Metric", style="cyan")
422
+ table.add_column("Value", justify="right")
423
+
424
+ brier = summary["brier_score"]
425
+ if brier is not None:
426
+ if brier < _BRIER_EXCELLENT:
427
+ interpretation = "[bold green]excellent[/bold green]"
428
+ elif brier < _BRIER_GOOD:
429
+ interpretation = "[yellow]good[/yellow]"
430
+ else:
431
+ interpretation = "[red]poor[/red]"
432
+ table.add_row("Brier Score", f"{brier:.4f} ({interpretation})")
433
+ else:
434
+ table.add_row("Brier Score", "[dim]no resolved data[/dim]")
435
+
436
+ table.add_row("Predictions", str(summary["n_predictions"]))
437
+ table.add_row("Resolved", str(summary["n_resolved"]))
438
+ table.add_row("Mean P(ITM)", f"{summary['mean_p_itm']:.2%}")
439
+ table.add_row("Hit Rate", f"{summary['hit_rate']:.2%}")
440
+
441
+ output_console.print(table)
442
+
443
+
444
+ @cache.command("prune-history")
445
+ @click.option("--max-age", type=int, default=30, help="Delete history files older than N days.")
446
+ def prune_history(max_age: int) -> None:
447
+ """Remove old volume history files."""
448
+ from optionctl.history import cleanup_old_history
449
+
450
+ removed = cleanup_old_history(max_age_days=max_age)
451
+ console.print(f"Removed {removed} old history files")
optionctl/cache.py ADDED
@@ -0,0 +1,224 @@
1
+ """Disk-based caching for option chain data with market-aware TTL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import UTC, datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+ from zoneinfo import ZoneInfo
11
+
12
+ if TYPE_CHECKING:
13
+ import pandas as pd
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _CACHE_DIR = Path.home() / ".cache" / "optionctl" / "chains"
18
+ _ET = ZoneInfo("America/New_York")
19
+
20
+
21
+ def _is_market_open() -> bool:
22
+ """Check if the US stock market is currently open.
23
+
24
+ Returns:
25
+ True if market is open (9:30 AM - 4:00 PM ET, weekdays).
26
+ """
27
+ now_et = datetime.now(_ET)
28
+
29
+ # Weekend
30
+ if now_et.weekday() >= 5: # noqa: PLR2004
31
+ return False
32
+
33
+ market_open = now_et.replace(hour=9, minute=30, second=0, microsecond=0)
34
+ market_close = now_et.replace(hour=16, minute=0, second=0, microsecond=0)
35
+
36
+ return market_open <= now_et <= market_close
37
+
38
+
39
+ def _is_cache_valid(cached_time: datetime) -> bool:
40
+ """Check if cached data is still valid based on market hours.
41
+
42
+ Args:
43
+ cached_time: When the data was cached (UTC).
44
+
45
+ Returns:
46
+ True if cache is still valid.
47
+ """
48
+ if _is_market_open():
49
+ # During market hours, 5 minute TTL
50
+ return datetime.now(UTC) - cached_time <= timedelta(minutes=5)
51
+
52
+ # After hours, valid until next market open
53
+ # But only if cached after previous market close
54
+ now_et = datetime.now(_ET)
55
+ cached_et = cached_time.astimezone(_ET)
56
+
57
+ # If cached today after 4pm, it's valid until tomorrow's open
58
+ today_close = now_et.replace(hour=16, minute=0, second=0, microsecond=0)
59
+ if cached_et.date() == now_et.date() and cached_et >= today_close:
60
+ return True
61
+
62
+ # If it's a weekend and cached on Friday after close, still valid
63
+ if now_et.weekday() >= 5: # noqa: PLR2004
64
+ # Find last Friday
65
+ days_since_friday = (now_et.weekday() - 4) % 7
66
+ last_friday = now_et - timedelta(days=days_since_friday)
67
+ friday_close = last_friday.replace(hour=16, minute=0, second=0, microsecond=0)
68
+ if cached_et >= friday_close:
69
+ return True
70
+
71
+ return False
72
+
73
+
74
+ _CACHE_VERSION = 2
75
+
76
+
77
+ def read_chain_cache(ticker: str) -> dict | None:
78
+ """Read cached option chain data for a ticker.
79
+
80
+ Args:
81
+ ticker: Stock ticker symbol.
82
+
83
+ Returns:
84
+ Dict with chain data, or None if cache miss/stale/old version.
85
+ """
86
+ path = _CACHE_DIR / f"{ticker.upper()}.json"
87
+
88
+ if not path.exists():
89
+ return None
90
+
91
+ try:
92
+ data = json.loads(path.read_text())
93
+ cached_time = datetime.fromisoformat(data["timestamp"])
94
+
95
+ if data.get("cache_version", 1) < _CACHE_VERSION:
96
+ logger.debug("Cache version outdated for %s, forcing re-fetch", ticker)
97
+ return None
98
+
99
+ if not _is_cache_valid(cached_time):
100
+ logger.debug("Cache expired for %s", ticker)
101
+ return None
102
+
103
+ logger.debug("Cache hit for %s", ticker)
104
+ except (json.JSONDecodeError, KeyError, TypeError, OSError, ValueError):
105
+ return None
106
+ else:
107
+ return data
108
+
109
+
110
+ def _df_to_records(df: pd.DataFrame) -> list[dict]:
111
+ """Convert a DataFrame to a list of dicts, handling pandas Timestamps.
112
+
113
+ Args:
114
+ df: DataFrame to convert.
115
+
116
+ Returns:
117
+ List of record dicts.
118
+ """
119
+ json_str = df.to_json(orient="records")
120
+ return json.loads(json_str) if json_str else []
121
+
122
+
123
+ def write_chain_cache(
124
+ ticker: str,
125
+ underlying_price: float,
126
+ expirations: list[str],
127
+ chains: dict[str, dict[str, pd.DataFrame]],
128
+ days_to_earnings: int | None = None,
129
+ ) -> None:
130
+ """Write option chain data to cache.
131
+
132
+ Args:
133
+ ticker: Stock ticker symbol.
134
+ underlying_price: Current stock price.
135
+ expirations: List of expiration date strings.
136
+ chains: Dict mapping expiration to {"calls": df, "puts": df}.
137
+ days_to_earnings: Days until next earnings, or None.
138
+ """
139
+ try:
140
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
141
+ path = _CACHE_DIR / f"{ticker.upper()}.json"
142
+
143
+ chains_data: dict[str, dict[str, list[dict]]] = {}
144
+ for exp, side_dfs in chains.items():
145
+ chains_data[exp] = {
146
+ "calls": _df_to_records(side_dfs["calls"]),
147
+ "puts": _df_to_records(side_dfs["puts"]),
148
+ }
149
+
150
+ payload = {
151
+ "cache_version": _CACHE_VERSION,
152
+ "timestamp": datetime.now(UTC).isoformat(),
153
+ "ticker": ticker.upper(),
154
+ "underlying_price": underlying_price,
155
+ "expirations": expirations,
156
+ "chains": chains_data,
157
+ "days_to_earnings": days_to_earnings,
158
+ }
159
+ path.write_text(json.dumps(payload))
160
+ logger.debug("Cached chain data for %s", ticker)
161
+ except OSError:
162
+ logger.debug("Failed to write cache for %s", ticker)
163
+
164
+
165
+ def write_no_options_cache(ticker: str) -> None:
166
+ """Cache that a ticker has no options available.
167
+
168
+ This prevents repeated failed fetches for tickers without options.
169
+
170
+ Args:
171
+ ticker: Stock ticker symbol.
172
+ """
173
+ try:
174
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
175
+ path = _CACHE_DIR / f"{ticker.upper()}.json"
176
+
177
+ payload = {
178
+ "timestamp": datetime.now(UTC).isoformat(),
179
+ "ticker": ticker.upper(),
180
+ "no_options": True,
181
+ }
182
+ path.write_text(json.dumps(payload))
183
+ logger.debug("Cached no-options marker for %s", ticker)
184
+ except OSError:
185
+ logger.debug("Failed to write no-options cache for %s", ticker)
186
+
187
+
188
+ def clear_cache() -> int:
189
+ """Clear all cached chain data.
190
+
191
+ Returns:
192
+ Number of cache files deleted.
193
+ """
194
+ if not _CACHE_DIR.exists():
195
+ return 0
196
+
197
+ count = 0
198
+ for path in _CACHE_DIR.glob("*.json"):
199
+ try:
200
+ path.unlink()
201
+ count += 1
202
+ except OSError:
203
+ pass
204
+ return count
205
+
206
+
207
+ def get_cache_stats() -> dict:
208
+ """Get cache statistics.
209
+
210
+ Returns:
211
+ Dict with cache stats (count, size, tickers).
212
+ """
213
+ if not _CACHE_DIR.exists():
214
+ return {"count": 0, "size_mb": 0.0, "tickers": []}
215
+
216
+ files = list(_CACHE_DIR.glob("*.json"))
217
+ total_size = sum(f.stat().st_size for f in files)
218
+ tickers = [f.stem for f in files]
219
+
220
+ return {
221
+ "count": len(files),
222
+ "size_mb": round(total_size / (1024 * 1024), 2),
223
+ "tickers": sorted(tickers),
224
+ }