057bc4493e
Wraps the entire yfinance public API behind a single binary `yfin` with
51 subcommands across 13 namespaces (ticker, download, tickers, search,
lookup, market, sector, industry, calendars, screen, live, auth, config).
Output renders as Rich tables on TTY, JSON when piped; --format / --out
support table | json | ndjson | csv | tsv | parquet | yaml.
Highlights:
- 1857 LOC across 18 Python files under cli/yfin/
- Typer + Rich, packaged via hatchling + uv
- Install globally: `cd cli && uv tool install -e .`
- ~/.yfin/config.toml for persistent proxy/cookies/locale defaults
- Friendly YFRateLimitError handler (exit 2, no Rich traceback)
- Validated end-to-end with 33 live tests against Yahoo from a
residential exit (AAPL, MSFT, NVDA, options, screens, calendars,
financials, news, holders, parquet round-trip)
yfinance-tools.yaml is the single source of truth:
- tools (49) — every public yfinance entry point with parameters
- cli (13 groups) — exact CLI invocations, mapped back to tool names
- outputSchemas (27) — actual return shapes captured from live calls
- learnings (19) — IP rate-limit, crumb handshake, Typer gotchas,
screen-build wrapping rules, upstream stubs, etc.
CLAUDE.md documents the upstream codebase layout for future agents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.0 KiB
Python
106 lines
3.0 KiB
Python
"""`yfin live ...` — Yahoo WebSocket streaming."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
from enum import Enum
|
|
from typing import List, Optional
|
|
|
|
import typer
|
|
import yfinance as yf
|
|
|
|
from .._shared import parse_symbols, get_state
|
|
|
|
|
|
class Mode(str, Enum):
|
|
sync = "sync"
|
|
async_ = "async"
|
|
|
|
|
|
app = typer.Typer(no_args_is_help=True, help="Stream live ticks via WebSocket.")
|
|
|
|
|
|
def _run_live(
|
|
symbols: List[str],
|
|
*, mode: str, url: Optional[str], verbose: bool,
|
|
duration: Optional[float], ctx: typer.Context,
|
|
):
|
|
"""Print decoded messages as NDJSON (or `--format` if you really want)."""
|
|
st = get_state(ctx)
|
|
out_path = st.out
|
|
fp = open(out_path, "w") if out_path else sys.stdout
|
|
|
|
def handler(msg: dict):
|
|
fp.write(json.dumps(msg, default=str, ensure_ascii=False) + "\n")
|
|
fp.flush()
|
|
|
|
kw = {"verbose": verbose}
|
|
if url:
|
|
kw["url"] = url
|
|
if mode == "async":
|
|
# Run the async variant in its own loop with a timeout.
|
|
import asyncio
|
|
|
|
async def _main():
|
|
ws = yf.AsyncWebSocket(**kw)
|
|
await ws.subscribe(symbols)
|
|
try:
|
|
await asyncio.wait_for(ws.listen(handler), timeout=duration)
|
|
except (asyncio.TimeoutError, KeyboardInterrupt):
|
|
pass
|
|
finally:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
asyncio.run(_main())
|
|
else:
|
|
ws = yf.WebSocket(**kw)
|
|
ws.subscribe(symbols)
|
|
if duration:
|
|
stopper = threading.Timer(duration, ws.close)
|
|
stopper.daemon = True
|
|
stopper.start()
|
|
try:
|
|
ws.listen(handler)
|
|
except KeyboardInterrupt:
|
|
ws.close()
|
|
if out_path:
|
|
fp.close()
|
|
|
|
|
|
@app.command("stream")
|
|
def stream(
|
|
ctx: typer.Context,
|
|
symbols: List[str] = typer.Argument(...),
|
|
mode: Mode = typer.Option(Mode.sync, "--mode"),
|
|
url: Optional[str] = typer.Option(None, "--url"),
|
|
verbose: bool = typer.Option(True, "--verbose/--no-verbose"),
|
|
duration: Optional[float] = typer.Option(None, "--duration", help="Close after N seconds."),
|
|
):
|
|
"""Subscribe + listen. Output is NDJSON to stdout (or --out PATH)."""
|
|
syms = parse_symbols(symbols)
|
|
_run_live(syms, mode=mode.value, url=url, verbose=verbose, duration=duration, ctx=ctx)
|
|
|
|
|
|
@app.command("subscribe")
|
|
def subscribe(ctx: typer.Context, symbols: List[str] = typer.Argument(...)):
|
|
"""One-shot subscribe frame (only meaningful inside an open session — kept for parity)."""
|
|
typer.echo(
|
|
"Note: `subscribe` only does something inside an existing live session. "
|
|
"Use `yfin live SYMBOL ...` for a real run.",
|
|
err=True,
|
|
)
|
|
|
|
|
|
@app.command("unsubscribe")
|
|
def unsubscribe(ctx: typer.Context, symbols: List[str] = typer.Argument(...)):
|
|
"""One-shot unsubscribe frame (same caveat as `subscribe`)."""
|
|
typer.echo(
|
|
"Note: `unsubscribe` only does something inside an existing live session.",
|
|
err=True,
|
|
)
|