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 +5 -0
- optionctl/__main__.py +6 -0
- optionctl/_cli.py +451 -0
- optionctl/cache.py +224 -0
- optionctl/candidates.py +64 -0
- optionctl/filters.py +118 -0
- optionctl/history.py +150 -0
- optionctl/models.py +85 -0
- optionctl/monte_carlo.py +44 -0
- optionctl/polygon.py +244 -0
- optionctl/predictions.py +219 -0
- optionctl/py.typed +0 -0
- optionctl/scanner.py +500 -0
- optionctl/scoring.py +242 -0
- optionctl/universe.py +121 -0
- optionctl/yfinance_source.py +204 -0
- optionctl-0.1.0.dist-info/METADATA +111 -0
- optionctl-0.1.0.dist-info/RECORD +21 -0
- optionctl-0.1.0.dist-info/WHEEL +4 -0
- optionctl-0.1.0.dist-info/entry_points.txt +3 -0
- optionctl-0.1.0.dist-info/licenses/LICENSE +177 -0
optionctl/__init__.py
ADDED
optionctl/__main__.py
ADDED
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
|
+
}
|