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>
372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""`yfin ticker ...` — single-symbol queries (22 commands)."""
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
import pandas as pd
|
|
import typer
|
|
import yfinance as yf
|
|
|
|
from .._shared import render_via_state, get_state
|
|
|
|
|
|
app = typer.Typer(no_args_is_help=True, help="Per-symbol queries (one symbol per call).")
|
|
|
|
|
|
class Period(str, Enum):
|
|
d1 = "1d"; d5 = "5d"; mo1 = "1mo"; mo3 = "3mo"; mo6 = "6mo"
|
|
y1 = "1y"; y2 = "2y"; y5 = "5y"; y10 = "10y"; ytd = "ytd"; max = "max"
|
|
|
|
|
|
class Interval(str, Enum):
|
|
m1 = "1m"; m2 = "2m"; m5 = "5m"; m15 = "15m"; m30 = "30m"
|
|
m60 = "60m"; m90 = "90m"; h1 = "1h"
|
|
d1 = "1d"; d5 = "5d"; wk1 = "1wk"; mo1 = "1mo"; mo3 = "3mo"
|
|
|
|
|
|
class ActionKind(str, Enum):
|
|
actions = "actions"; dividends = "dividends"; splits = "splits"; capital_gains = "capital_gains"
|
|
|
|
|
|
class StatementKind(str, Enum):
|
|
income_stmt = "income_stmt"; balance_sheet = "balance_sheet"; cash_flow = "cash_flow"
|
|
|
|
|
|
class Freq(str, Enum):
|
|
yearly = "yearly"; quarterly = "quarterly"; trailing = "trailing"
|
|
|
|
|
|
class AnalystKind(str, Enum):
|
|
analyst_price_targets = "analyst_price_targets"
|
|
earnings_estimate = "earnings_estimate"
|
|
revenue_estimate = "revenue_estimate"
|
|
earnings_history = "earnings_history"
|
|
eps_trend = "eps_trend"
|
|
eps_revisions = "eps_revisions"
|
|
growth_estimates = "growth_estimates"
|
|
recommendations = "recommendations"
|
|
recommendations_summary = "recommendations_summary"
|
|
upgrades_downgrades = "upgrades_downgrades"
|
|
|
|
|
|
class HolderKind(str, Enum):
|
|
major_holders = "major_holders"
|
|
institutional_holders = "institutional_holders"
|
|
mutualfund_holders = "mutualfund_holders"
|
|
insider_purchases = "insider_purchases"
|
|
insider_transactions = "insider_transactions"
|
|
insider_roster_holders = "insider_roster_holders"
|
|
|
|
|
|
class NewsTab(str, Enum):
|
|
news = "news"; all = "all"; press = "press releases"
|
|
|
|
|
|
def _t(symbol: str) -> yf.Ticker:
|
|
return yf.Ticker(symbol.upper())
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Profile / quote
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("info")
|
|
def info(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""Full company / instrument profile dict (heavy)."""
|
|
render_via_state(ctx, _t(symbol).get_info())
|
|
|
|
|
|
@app.command("fast-info")
|
|
def fast_info(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""Lightweight quote snapshot (price, ranges, market cap)."""
|
|
fi = _t(symbol).get_fast_info()
|
|
render_via_state(ctx, {k: fi.get(k) for k in fi.keys()})
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# History
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("history")
|
|
def history(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
period: Optional[Period] = typer.Option(None, "--period"),
|
|
interval: Interval = typer.Option(Interval.d1, "--interval"),
|
|
start: Optional[str] = typer.Option(None, "--start"),
|
|
end: Optional[str] = typer.Option(None, "--end"),
|
|
prepost: bool = typer.Option(False, "--prepost/--no-prepost"),
|
|
actions: bool = typer.Option(True, "--actions/--no-actions"),
|
|
auto_adjust: bool = typer.Option(True, "--auto-adjust/--no-auto-adjust"),
|
|
back_adjust: bool = typer.Option(False, "--back-adjust/--no-back-adjust"),
|
|
repair: bool = typer.Option(False, "--repair/--no-repair"),
|
|
keepna: bool = typer.Option(False, "--keepna/--no-keepna"),
|
|
rounding: bool = typer.Option(False, "--rounding/--no-rounding"),
|
|
):
|
|
"""OHLCV history DataFrame."""
|
|
st = get_state(ctx)
|
|
kw = dict(
|
|
interval=interval.value,
|
|
prepost=prepost, actions=actions,
|
|
auto_adjust=auto_adjust, back_adjust=back_adjust,
|
|
repair=repair, keepna=keepna, rounding=rounding,
|
|
timeout=st.timeout,
|
|
)
|
|
if period and not (start or end):
|
|
kw["period"] = period.value
|
|
if start:
|
|
kw["start"] = start
|
|
if end:
|
|
kw["end"] = end
|
|
df = _t(symbol).history(**kw)
|
|
render_via_state(ctx, df)
|
|
|
|
|
|
@app.command("history-metadata")
|
|
def history_metadata(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""Yahoo chart-response metadata (exchange/tz/currency/hours/etc.)."""
|
|
render_via_state(ctx, _t(symbol).get_history_metadata())
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Actions
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("actions")
|
|
def actions_cmd(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
kind: ActionKind = typer.Option(ActionKind.actions, "--kind"),
|
|
period: Period = typer.Option(Period.max, "--period"),
|
|
):
|
|
"""Dividends / splits / capital gains (or combined)."""
|
|
t = _t(symbol)
|
|
if kind is ActionKind.actions:
|
|
out = t.get_actions(period=period.value)
|
|
elif kind is ActionKind.dividends:
|
|
out = t.get_dividends(period=period.value)
|
|
elif kind is ActionKind.splits:
|
|
out = t.get_splits(period=period.value)
|
|
else:
|
|
out = t.get_capital_gains(period=period.value)
|
|
render_via_state(ctx, out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Fundamentals
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("financials")
|
|
def financials(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
statement: StatementKind = typer.Option(..., "--statement"),
|
|
freq: Freq = typer.Option(Freq.yearly, "--freq"),
|
|
pretty_rows: bool = typer.Option(False, "--pretty-rows/--no-pretty-rows"),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""Income statement / balance sheet / cash flow."""
|
|
t = _t(symbol)
|
|
kw = dict(as_dict=as_dict, pretty=pretty_rows, freq=freq.value)
|
|
if statement is StatementKind.income_stmt:
|
|
out = t.get_income_stmt(**kw)
|
|
elif statement is StatementKind.balance_sheet:
|
|
# balance_sheet has no 'trailing'; clamp.
|
|
if freq is Freq.trailing:
|
|
kw["freq"] = "yearly"
|
|
out = t.get_balance_sheet(**kw)
|
|
else:
|
|
out = t.get_cash_flow(**kw)
|
|
render_via_state(ctx, out)
|
|
|
|
|
|
@app.command("valuation")
|
|
def valuation(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""Historical valuation ratios."""
|
|
render_via_state(ctx, _t(symbol).get_valuation_measures())
|
|
|
|
|
|
@app.command("earnings")
|
|
def earnings(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
freq: Freq = typer.Option(Freq.yearly, "--freq"),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""Historical revenue + earnings."""
|
|
if freq is Freq.trailing:
|
|
freq = Freq.yearly
|
|
render_via_state(ctx, _t(symbol).get_earnings(as_dict=as_dict, freq=freq.value))
|
|
|
|
|
|
@app.command("earnings-dates")
|
|
def earnings_dates(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
limit: int = typer.Option(12, "--limit"),
|
|
offset: int = typer.Option(0, "--offset"),
|
|
):
|
|
"""Upcoming + recent earnings dates with surprise %."""
|
|
render_via_state(ctx, _t(symbol).get_earnings_dates(limit=limit, offset=offset))
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Analysts
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("analyst")
|
|
def analyst(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
kind: AnalystKind = typer.Option(..., "--kind"),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""Forward-looking analyst data (10 datasets)."""
|
|
t = _t(symbol)
|
|
name = kind.value
|
|
fn = getattr(t, f"get_{name}")
|
|
try:
|
|
out = fn(as_dict=as_dict)
|
|
except TypeError:
|
|
out = fn() # analyst_price_targets has no as_dict
|
|
render_via_state(ctx, out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Holders
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("holders")
|
|
def holders(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
kind: HolderKind = typer.Option(..., "--kind"),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""Ownership tables (major / institutional / mutualfund / insider)."""
|
|
t = _t(symbol)
|
|
out = getattr(t, f"get_{kind.value}")(as_dict=as_dict)
|
|
render_via_state(ctx, out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Calendar / news / SEC / sustainability / ISIN
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("calendar")
|
|
def calendar(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""Per-symbol upcoming earnings / dividend / split events."""
|
|
render_via_state(ctx, _t(symbol).get_calendar())
|
|
|
|
|
|
@app.command("news")
|
|
def news(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
count: int = typer.Option(10, "--count"),
|
|
tab: NewsTab = typer.Option(NewsTab.news, "--tab"),
|
|
):
|
|
"""Recent Yahoo Finance news."""
|
|
render_via_state(ctx, _t(symbol).get_news(count=count, tab=tab.value))
|
|
|
|
|
|
@app.command("sec-filings")
|
|
def sec_filings(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""SEC filings (10-K / 10-Q / 8-K / ...)."""
|
|
render_via_state(ctx, _t(symbol).get_sec_filings())
|
|
|
|
|
|
@app.command("sustainability")
|
|
def sustainability(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""ESG / sustainability scores."""
|
|
render_via_state(ctx, _t(symbol).get_sustainability(as_dict=as_dict))
|
|
|
|
|
|
@app.command("isin")
|
|
def isin(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""ISIN lookup (experimental)."""
|
|
render_via_state(ctx, {"symbol": symbol.upper(), "isin": _t(symbol).get_isin()})
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Shares
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("shares")
|
|
def shares(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
as_dict: bool = typer.Option(False, "--as-dict/--no-as-dict"),
|
|
):
|
|
"""Share-count history."""
|
|
render_via_state(ctx, _t(symbol).get_shares(as_dict=as_dict))
|
|
|
|
|
|
@app.command("shares-full")
|
|
def shares_full(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
start: Optional[str] = typer.Option(None, "--start"),
|
|
end: Optional[str] = typer.Option(None, "--end"),
|
|
):
|
|
"""Daily shares-outstanding series."""
|
|
render_via_state(ctx, _t(symbol).get_shares_full(start=start, end=end))
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Options
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("options")
|
|
def options(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""List available option expiration dates."""
|
|
render_via_state(ctx, list(_t(symbol).options))
|
|
|
|
|
|
@app.command("option-chain")
|
|
def option_chain(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
date: Optional[str] = typer.Option(None, "--date"),
|
|
tz: Optional[str] = typer.Option(None, "--tz"),
|
|
):
|
|
"""Calls + puts + underlying for one expiration."""
|
|
oc = _t(symbol).option_chain(date=date, tz=tz)
|
|
render_via_state(ctx, oc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Funds
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("funds-data")
|
|
def funds_data(ctx: typer.Context, symbol: str = typer.Argument(...)):
|
|
"""ETF / mutual-fund detail bundle."""
|
|
fd = _t(symbol).get_funds_data()
|
|
if fd is None:
|
|
render_via_state(ctx, None)
|
|
return
|
|
out = {
|
|
"quote_type": fd.quote_type,
|
|
"description": fd.description,
|
|
"fund_overview": fd.fund_overview,
|
|
"fund_operations": fd.fund_operations,
|
|
"asset_classes": fd.asset_classes,
|
|
"top_holdings": fd.top_holdings,
|
|
"equity_holdings": fd.equity_holdings,
|
|
"bond_holdings": fd.bond_holdings,
|
|
"bond_ratings": fd.bond_ratings,
|
|
"sector_weightings": fd.sector_weightings,
|
|
}
|
|
render_via_state(ctx, out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------- #
|
|
# Live (single-symbol)
|
|
# ---------------------------------------------------------------------------- #
|
|
@app.command("live")
|
|
def live(
|
|
ctx: typer.Context,
|
|
symbol: str = typer.Argument(...),
|
|
verbose: bool = typer.Option(True, "--verbose/--no-verbose"),
|
|
duration: Optional[float] = typer.Option(None, "--duration", help="Stop after N seconds."),
|
|
):
|
|
"""Stream live ticks for ONE symbol via WebSocket."""
|
|
from . import live as live_mod
|
|
live_mod._run_live([symbol], mode="sync", url=None, verbose=verbose, duration=duration, ctx=ctx)
|