Skip to content

Commit 12034d3

Browse files
michaelchuclaude
andauthored
Add symbols CLI command and stock download flag (#212)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ee2998 commit 12034d3

File tree

3 files changed

+147
-1
lines changed

3 files changed

+147
-1
lines changed

optopsy/ui/cli.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def _cmd_download(args: argparse.Namespace) -> None:
8282
level=level,
8383
)
8484

85+
if getattr(args, "stocks", False):
86+
for symbol in args.symbols:
87+
_download_stocks_with_rich(symbol.upper())
88+
return
89+
8590
from optopsy.ui.providers import get_provider_for_tool
8691
from optopsy.ui.providers.eodhd import EODHDProvider
8792

@@ -168,6 +173,97 @@ def _on_status(msg: str) -> None:
168173
console.print(f"\n{summary}")
169174

170175

176+
def _download_stocks_with_rich(symbol: str) -> None:
177+
"""Download stock/index OHLCV data via yfinance with Rich progress display."""
178+
from datetime import date
179+
180+
import pandas as pd
181+
from rich.console import Console
182+
183+
from optopsy.ui.providers.cache import ParquetCache
184+
from optopsy.ui.tools._helpers import _YF_CACHE_CATEGORY, _yf_fetch_and_cache
185+
186+
console = Console()
187+
console.rule(f"Downloading stock data for {symbol}")
188+
189+
cache = ParquetCache()
190+
cached = cache.read(_YF_CACHE_CATEGORY, symbol)
191+
192+
with console.status(f"[bold green]Fetching {symbol} from yfinance…"):
193+
try:
194+
result = _yf_fetch_and_cache(symbol, cached, date.today())
195+
except (OSError, ValueError) as exc:
196+
console.print(f" [red]Error fetching {symbol}: {exc}[/red]")
197+
return
198+
199+
if result is None or result.empty:
200+
console.print(f" [yellow]No data returned for {symbol}.[/yellow]")
201+
return
202+
203+
date_min = pd.to_datetime(result["date"]).dt.date.min()
204+
date_max = pd.to_datetime(result["date"]).dt.date.max()
205+
row_count = len(result)
206+
207+
size_bytes = cache.size().get(f"{_YF_CACHE_CATEGORY}/{symbol}", 0)
208+
size_str = _format_bytes(size_bytes)
209+
210+
console.print(f" [bold]{symbol}[/bold] {date_min}{date_max}")
211+
console.print(
212+
f" [cyan]{row_count:,} rows[/cyan] [dim]({size_str} on disk)[/dim]\n"
213+
)
214+
215+
216+
def _cmd_symbols(args: argparse.Namespace) -> None:
217+
"""List symbols that have options data available from configured providers."""
218+
from rich.columns import Columns
219+
from rich.console import Console
220+
221+
_load_env()
222+
223+
from optopsy.ui.providers import get_available_providers
224+
225+
console = Console()
226+
search = getattr(args, "search", None)
227+
use_pager = not search and console.is_terminal
228+
found = False
229+
230+
def _render() -> None:
231+
nonlocal found
232+
for provider in get_available_providers():
233+
if not provider.is_available():
234+
continue
235+
symbols = provider.list_available_symbols()
236+
if symbols is None:
237+
continue
238+
found = True
239+
240+
if search:
241+
term = search.upper()
242+
symbols = [s for s in symbols if term in s]
243+
244+
if not symbols:
245+
console.print(
246+
f"[yellow]No symbols matching '{search}' from {provider.name}.[/yellow]"
247+
)
248+
continue
249+
250+
console.rule(f"{provider.name}{len(symbols):,} symbols")
251+
console.print(Columns(symbols, padding=(0, 2), column_first=True))
252+
console.print()
253+
254+
if use_pager:
255+
with console.pager(styles=True):
256+
_render()
257+
else:
258+
_render()
259+
260+
if not found:
261+
console.print(
262+
"[yellow]No data provider supports listing symbols.\n"
263+
"Set EODHD_API_KEY in your environment or .env file.[/yellow]"
264+
)
265+
266+
171267
def _cmd_run(args: argparse.Namespace) -> None:
172268
"""Configure environment and launch the Chainlit server."""
173269
if args.host:
@@ -250,18 +346,44 @@ def main(argv: list[str] | None = None) -> None:
250346
# --- download ---
251347
dl_parser = subparsers.add_parser(
252348
"download",
253-
help="Download historical options data for one or more symbols",
349+
help="Download historical market data for one or more symbols",
254350
)
255351
dl_parser.add_argument(
256352
"symbols",
257353
nargs="+",
258354
help="One or more US stock ticker symbols (e.g. SPY AAPL TSLA)",
259355
)
356+
dl_group = dl_parser.add_mutually_exclusive_group()
357+
dl_group.add_argument(
358+
"-o",
359+
"--options",
360+
action="store_true",
361+
default=True,
362+
help="Download options chain data via EODHD (default)",
363+
)
364+
dl_group.add_argument(
365+
"-s",
366+
"--stocks",
367+
action="store_true",
368+
help="Download stock/index OHLCV data via yfinance (no API key needed)",
369+
)
260370
dl_parser.add_argument(
261371
"-v", "--verbose", action="store_true", help="Enable debug logging"
262372
)
263373
dl_parser.set_defaults(func=_cmd_download)
264374

375+
# --- symbols ---
376+
sym_parser = subparsers.add_parser(
377+
"symbols", help="List symbols with options data available for download"
378+
)
379+
sym_parser.add_argument(
380+
"-q",
381+
"--search",
382+
default=None,
383+
help="Filter symbols containing TERM (case-insensitive)",
384+
)
385+
sym_parser.set_defaults(func=_cmd_symbols)
386+
265387
# --- cache ---
266388
cache_parser = subparsers.add_parser("cache", help="Manage the data cache")
267389
cache_sub = cache_parser.add_subparsers(dest="cache_command")

optopsy/ui/providers/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ def replaces_dataset(self, tool_name: str) -> bool:
150150
"""
151151
return True
152152

153+
def list_available_symbols(self) -> list[str] | None:
154+
"""Return symbols available for options download, or None if unsupported."""
155+
return None
156+
153157
@abstractmethod
154158
def execute(
155159
self, tool_name: str, arguments: dict[str, Any]

optopsy/ui/providers/eodhd.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,26 @@ def name(self) -> str:
163163
def env_key(self) -> str:
164164
return "EODHD_API_KEY"
165165

166+
def list_available_symbols(self) -> list[str] | None:
167+
"""Fetch the list of symbols with options data from EODHD."""
168+
api_key = self._get_api_key()
169+
if not api_key:
170+
return None
171+
try:
172+
resp = self._throttled_get(
173+
f"{_BASE_URL}/options/underlying-symbols",
174+
{"api_token": api_key},
175+
)
176+
error = _check_response(resp)
177+
if error:
178+
_log.warning("Failed to fetch available symbols: %s", error)
179+
return None
180+
_safe_raise_for_status(resp)
181+
return resp.json().get("data", [])
182+
except Exception as exc:
183+
_log.warning("Failed to fetch available symbols: %s", exc)
184+
return None
185+
166186
def get_tool_schemas(self) -> list[dict[str, Any]]:
167187
return [self._download_schema(), self._options_schema()]
168188

0 commit comments

Comments
 (0)