Compare commits

...

10 Commits

Author SHA1 Message Date
goldyard 057bc4493e Add yfin CLI + tool catalog + CLAUDE.md
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>
2026-06-08 23:36:56 +08:00
ValueRaider 125b12e058 Version 1.4.1
Build and Deploy Sphinx Docs / build (push) Has been cancelled
zizmor / zizmor (push) Has been cancelled
2026-05-28 21:01:28 +01:00
ValueRaider 15ba0223cc Merge pull request #2836 from ranaroussi/dev
sync dev -> main
2026-05-28 21:00:11 +01:00
Ran Aroussi 0940e44e97 docs: changelog entry for 1df338c 2026-05-25 21:55:38 +01:00
Ran Aroussi 863f0a0be7 docs: changelog entry for c3db565 2026-05-25 21:55:37 +01:00
ValueRaider 1df338cce6 Merge pull request #2832 from dokson/fix/download-index-name
Preserve Date/Datetime index name in yf.download() output
2026-05-25 21:55:06 +01:00
Alessandro Colace c3db565d09 Preserve Date/Datetime index name in yf.download() output
reindex_dfs() rebuilt the combined index via sorted(set(...)) +
pd.to_datetime, dropping the index name set by history.py ('Date' for
daily, 'Datetime' for intraday). After concat, data.index.name was
None, so users doing data.stack().reset_index() got a 'level_0' column
instead of 'Date' / 'Datetime'.

Use Index.union, which preserves the name natively when sources agree.

Drive-by: narrow data.columns to MultiIndex before swaplevel to silence
a pyright attribute warning.
2026-05-25 22:20:29 +02:00
Ran Aroussi 6b84cdecd7 docs: changelog entry for 754346f 2026-05-23 17:29:51 +01:00
Ran Aroussi 6f0525b199 docs: changelog entry for e005d2d 2026-05-23 17:29:50 +01:00
Ran Aroussi 0118573c9e docs: changelog entry for ff36bd7 2026-05-23 17:29:49 +01:00
31 changed files with 6046 additions and 7 deletions
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Merge pull request #2832 from dokson/fix/download-index-name ([#2832]()) (1df338c)
- Preserve Date/Datetime index name in yf.download() output (c3db565)
- Merge pull request #2830 from ranaroussi/main ([#2830]()) (754346f)
- Version 1.4.0 (e005d2d)
- Merge pull request #2828 from ranaroussi/dev ([#2828]()) (ff36bd7)
- Merge pull request #2829 from ranaroussi/dependabot/github_actions/zizmorcore/zizmor-action-0.5.6 ([#2829]()) (44cc9ba)
- Bump zizmorcore/zizmor-action from 0.5.3 to 0.5.6 (89ad803)
+5
View File
@@ -1,6 +1,11 @@
Change Log
===========
1.4.1
-----
Fix
- Preserve Date/Datetime index name in yf.download() output #2832
1.4.0
-----
Features
+89
View File
@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
`yfinance` is a Python library (v1.4.1) that downloads market data from Yahoo! Finance's public web APIs. It is not affiliated with Yahoo — it reverse-engineers their v7/v10/v8 chart, quote, options, fundamentals, and websocket streaming endpoints. The package is published on PyPI and conda-forge.
## Commands
### Tests
Tests use the stdlib `unittest` runner. They hit real Yahoo endpoints, so a network connection is required and individual tests can be flaky against live data.
```bash
# Run everything
python -m unittest discover -s tests
# Single module
python -m unittest tests.test_ticker
# Single test
python -m unittest tests.test_ticker.TestTicker.test_history
# With coverage (mirrors CI)
pytest --cov=yfinance/
```
All test modules import `tests/context.py` first — it inserts the repo root onto `sys.path` and configures a dedicated test cache at `<user_cache_dir>/py-yfinance-testing` that auto-clears once per day. `tests/context.py` also exposes `session_gbl` (currently `None`); requests_cache/requests_ratelimiter sessions don't compose with `curl_cffi` so the rate-limiter examples are kept commented out as reference.
### Local install
```bash
pip install -e .
```
`extras_require`: `nospam` (requests_cache + requests_ratelimiter) and `repair` (scipy, needed for `Ticker.history(repair=True)`).
### Type check
Pyright is configured via `pyrightconfig.json` in basic mode with most checks at `warning` level.
### Branching for PRs
Two-layer model (per `CONTRIBUTING.md`): submit PRs against **`dev`**. The maintainer batches dev → **`main`** for each PyPI release. Do not target `main` directly. CI (`.travis.yml`) only runs on `main`.
## Architecture
### Public surface
`yfinance/__init__.py` is the single entry point that re-exports the entire public API. Anything not exported there is private. The public types are: `Ticker`, `Tickers`, `download`, `Market`/`MarketRegion`, `Search`, `Lookup`, `Calendars`, `Sector`, `Industry`, `WebSocket`/`AsyncWebSocket`, `EquityQuery`/`FundQuery`/`ETFQuery`, `screen`, `PREDEFINED_SCREENER_QUERIES`, `Auth`, `config`, `enable_debug_mode`, `set_tz_cache_location`.
### Ticker is a thin facade over scrapers
`Ticker` (yfinance/ticker.py) inherits from `TickerBase` (yfinance/base.py) and exposes ~50 `@property` accessors (`info`, `financials`, `balance_sheet`, `news`, `options`, `funds_data`, etc.). Each property just calls a `get_*()` method on `TickerBase`. The actual fetching/parsing logic for each property lives in a dedicated scraper module under `yfinance/scrapers/`:
- `quote.py``info`, `fast_info`, `calendar`, `sec_filings`, `news`, ISIN
- `fundamentals.py` — income statement, balance sheet, cash flow, earnings
- `analysis.py` — analyst estimates, price targets, revisions, growth
- `holders.py` — major/institutional/mutualfund/insider holders
- `history.py` — OHLCV history, splits, dividends, price-repair (the largest file: ~164k)
- `funds.py``FundsData` for ETFs/mutual funds
When adding a new ticker field, follow the established pattern: implement `get_<thing>()` in the relevant scraper, wire it through `TickerBase`, then add a `@property` on `Ticker`.
### Multi-ticker downloads
`Tickers` (tickers.py) holds a dict of `Ticker` instances. `download()` (multi.py) is the heavy-lift batch path used for portfolio history — it parallelizes via the `multitasking` package and reshapes results into a single (multi-indexed) DataFrame. Don't extend `Ticker.history()` with batching logic; that belongs in `multi.py`.
### HTTP and caching
- `yfinance/data.py``YfData` (the per-session HTTP wrapper) and the `Auth` helper that solves Yahoo's crumb/cookie challenge. Every scraper goes through `self._data.get(...)`.
- `yfinance/_http.py` — low-level HTTP utilities.
- `curl_cffi` is the default backend (it impersonates a browser TLS fingerprint, which is required to get past Yahoo's Cloudflare). Plain `requests`/`requests_cache`/`requests_ratelimiter` are not compatible — that's why `tests/context.py` keeps `session_gbl = None` and why the `nospam` extra is opt-in only for users supplying their own `requests`-based session.
- `yfinance/cache.py` — peewee + SQLite caches for tz lookups and the IPO/cookie cache. User-facing location is set via `yf.set_tz_cache_location(path)`.
### Live streaming
`yfinance/live.py` provides `WebSocket` and `AsyncWebSocket` for Yahoo's real-time pricing feed. Wire-format is protobuf — `yfinance/pricing.proto` is the schema and `yfinance/pricing_pb2.py` is the generated code (both ship in the wheel via `setup.py` `package_data`). Regenerate `pricing_pb2.py` with `protoc` if the schema changes.
### Domain / screener
- `yfinance/domain/``Sector`, `Industry`, `Market`/`MarketRegion` (sector overviews, industry summaries, market summary endpoints). They share a `domain.py` base.
- `yfinance/screener/``query.py` defines the typed `EquityQuery`/`FundQuery`/`ETFQuery` builders; `screener.py` defines `screen()` and `PREDEFINED_SCREENER_QUERIES`. New screener fields go in `query.py` keyed off Yahoo's screenable-field metadata.
### Constants & utilities
- `yfinance/const.py` (~48k) — every magic string from Yahoo: financial-statement field name maps (`fundamentals_keys`), pretty-name translation tables, user-agents, base URLs (`_BASE_URL_`, `_ROOT_URL_`, `_SCRAPE_URL_`), screener-field enums. Look here first before hard-coding any Yahoo field name.
- `yfinance/utils.py` (~47k) — shared parsers, the project-wide `get_yf_logger()`, decorators, DataFrame helpers (`empty_df`, `_pd_timedelta_to_interval`), etc.
### Config
`yfinance/config.py` defines `YfConfig` (exported as `yfinance.config`), a tiny nested-attribute store with auto-vivifying namespaces. The defaults are `config.network.proxy`, `config.network.retries`, `config.debug.hide_exceptions`, `config.debug.logging`, `config.locale.lang`, `config.locale.region`. The legacy `set_config(proxy=, retries=)` is a deprecated shim that forwards to `config.network.*`.
## Conventions and gotchas
- **Version bump**: edit `yfinance/version.py` (`setup.py` reads it textually — keep the `version = "x.y.z"` line shape) and `meta.yaml` (`{% set version = "..." %}`) together. `meta.yaml` is for conda-forge and includes a `sha256` that the conda-forge bot updates after PyPI release.
- **Versioning is loose**: bumps are not always semver-aligned with API changes. Check `CHANGELOG.rst` (the canonical changelog; `CHANGELOG.md` is the short rolling extract).
- **Logging**: scrapers should call `utils.get_yf_logger()` rather than the stdlib `logging` module directly. End-users opt in with `yf.enable_debug_mode()`.
- **Frequencies on financials**: `freq` is one of `'yearly'`, `'quarterly'`, `'trailing'` (TTM). The `pretty=True` flag on `get_income_stmt`/`get_balance_sheet`/`get_cash_flow` swaps Yahoo's raw camelCase keys for human-readable row labels via maps in `const.py`.
- **Price repair**: `Ticker.history(..., repair=True)` runs the heuristics in `scrapers/history.py` against bad-tick patterns Yahoo emits (100x/0.01x splits, missing dividends). It needs `scipy` — guard new repair code behind the existing optional-import pattern.
- **Tests hit the network**: there is no recorded-fixture infrastructure. When debugging a failing test, expect that the underlying Yahoo endpoint may have changed shape; check `scrapers/yahoo-keys.txt` for the latest observed key set.
+12
View File
@@ -0,0 +1,12 @@
# Python / uv
.venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Build artifacts
dist/
build/
*.egg-info/
+24
View File
@@ -0,0 +1,24 @@
# yfin
Single-binary CLI wrapper around [yfinance](https://github.com/ranaroussi/yfinance). 50 subcommands covering every public yfinance entry point. Outputs Rich tables on a TTY, JSON when piped, CSV/Parquet/NDJSON/YAML on demand.
See `../yfinance-tools.yaml` (the `cli:` section) for the full command map.
## Install (editable, global)
```bash
uv tool install -e .
yfin --help
```
## Quick examples
```bash
yfin ticker fast-info AAPL
yfin ticker history AAPL --period 1mo --interval 1d --out aapl.parquet
yfin download AAPL MSFT NVDA --start 2024-01-01 --out portfolio.csv
yfin search "anthropic" --news-count 5
yfin screen predefined day_gainers --count 25
yfin market summary US
yfin calendars earnings --start 2026-06-09 --end 2026-06-16
```
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "yfin"
version = "0.1.0"
description = "Single-binary CLI for the yfinance library — Pythonic access to Yahoo! Finance data from the shell."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "yfin contributors" }]
license = { text = "Apache-2.0" }
classifiers = [
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: Office/Business :: Financial",
]
dependencies = [
"typer>=0.12",
"rich>=13",
"yfinance>=1.4.1",
"pyarrow>=14",
"PyYAML>=6",
"pandas>=1.3.0",
"lxml>=4.9", # earnings_dates uses pandas.read_html
"html5lib>=1.1", # extra HTML backend
"requests_cache>=1.0", # `nospam` extra
"requests_ratelimiter>=0.3.1",
"scipy>=1.6.3", # repair=True heuristics
]
[project.scripts]
yfin = "yfin.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["yfin"]
Generated
+1376
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
"""yfin — single-binary CLI for yfinance."""
__version__ = "0.1.0"
+166
View File
@@ -0,0 +1,166 @@
"""yfin root Typer app."""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Optional
import typer
import yfinance as yf
from rich.console import Console
from yfinance.exceptions import YFRateLimitError
from . import __version__, config as cfg_mod, renderers
from ._shared import GlobalState
from .commands import (
auth as auth_cmd,
calendars as calendars_cmd,
config as config_cmd,
download as download_cmd,
industry as industry_cmd,
live as live_cmd,
lookup as lookup_cmd,
market as market_cmd,
screen as screen_cmd,
search as search_cmd,
sector as sector_cmd,
ticker as ticker_cmd,
tickers as tickers_cmd,
)
app = typer.Typer(
name="yfin",
no_args_is_help=True,
add_completion=True,
rich_markup_mode="rich",
context_settings={"help_option_names": ["-h", "--help"]},
help=(
"Single-binary CLI for the [bold]yfinance[/bold] library.\n\n"
"All 49 wrapped yfinance entry points are reachable as "
"[cyan]yfin <group> <subcommand>[/cyan]. Output defaults to a Rich table on "
"a TTY, JSON when piped. Override with [cyan]--format[/cyan] / [cyan]--out[/cyan]."
),
)
# Subcommand groups (must match cli.commandGroups in yfinance-tools.yaml).
app.add_typer(ticker_cmd.app, name="ticker", help="Per-symbol queries (one symbol per call).")
app.add_typer(tickers_cmd.app, name="tickers", help="Tickers-collection operations (history/news).")
app.add_typer(search_cmd.app, name="search", help="Yahoo Finance search by keyword.")
app.add_typer(lookup_cmd.app, name="lookup", help="Fuzzy symbol lookup.")
app.add_typer(market_cmd.app, name="market", help="Region-level overview / status.")
app.add_typer(sector_cmd.app, name="sector", help="Sector aggregates.")
app.add_typer(industry_cmd.app, name="industry", help="Industry aggregates.")
app.add_typer(calendars_cmd.app, name="calendars", help="Market-wide event calendars.")
app.add_typer(screen_cmd.app, name="screen", help="Predefined + custom market screens.")
app.add_typer(live_cmd.app, name="live", help="Stream live ticks via Yahoo WebSocket.")
app.add_typer(auth_cmd.app, name="auth", help="Yahoo cookie / strategy management.")
app.add_typer(config_cmd.app, name="config", help="yfin / yfinance config.")
# Top-level shortcut: `yfin download ...` (most-used multi-ticker path).
app.command(name="download", help="Batch OHLCV download (top-level shortcut for `yfin tickers history`).")(
download_cmd.download
)
def _version_callback(value: bool):
if value:
typer.echo(f"yfin {__version__}")
typer.echo(f"yfinance {yf.__version__}")
raise typer.Exit()
@app.callback()
def root(
ctx: typer.Context,
fmt: Optional[str] = typer.Option(
None, "--format", "-f", help="Output format: table|json|ndjson|csv|tsv|parquet|yaml.",
case_sensitive=False,
),
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to PATH instead of stdout."),
pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON/YAML output."),
compact: bool = typer.Option(False, "--compact", help="Single-line JSON output."),
columns: Optional[str] = typer.Option(None, "--columns", help="Comma-separated columns to keep."),
limit: Optional[int] = typer.Option(None, "--limit", help="Truncate rendered output to N rows."),
proxy: Optional[str] = typer.Option(None, "--proxy", help="HTTP/HTTPS proxy URL."),
timeout: float = typer.Option(10.0, "--timeout", help="Request timeout in seconds."),
retries: int = typer.Option(0, "--retries", help="HTTP retry attempts."),
lang: str = typer.Option("en-US", "--lang", help="BCP-47 language tag."),
region: str = typer.Option("US", "--region", help="ISO 3166-1 alpha-2 country code."),
cache_dir: Optional[str] = typer.Option(None, "--cache-dir", help="Override tz/cookie SQLite cache directory."),
config_path: Optional[Path] = typer.Option(None, "--config", help="Use a non-default ~/.yfin/config.toml."),
no_color: bool = typer.Option(False, "--no-color", help="Disable Rich color output."),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress bars and warnings."),
verbose: int = typer.Option(0, "--verbose", "-v", count=True, help="-v=INFO, -vv=DEBUG."),
version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Print versions and exit."),
):
"""Set global state on ctx.obj and apply yf.config / cache / cookies before subcommand runs."""
# Load config file (defaults), then layer flags on top.
file_cfg = cfg_mod.load(config_path)
st = GlobalState(
fmt=fmt,
out=out,
pretty=pretty,
compact=compact,
columns=columns,
limit=limit,
proxy=proxy or file_cfg.proxy,
timeout=timeout if timeout != 10.0 else file_cfg.timeout,
retries=retries or file_cfg.retries,
lang=lang if lang != "en-US" else file_cfg.lang,
region=region if region != "US" else file_cfg.region,
cache_dir=cache_dir or file_cfg.cache_dir,
config_path=config_path,
no_color=no_color,
quiet=quiet,
verbose=verbose,
cookies_t=file_cfg.cookies_t,
cookies_y=file_cfg.cookies_y,
)
if st.fmt is None and file_cfg.default_format:
st.fmt = file_cfg.default_format
ctx.obj = st
# Apply to yfinance global config singleton.
if st.proxy:
yf.config.network.proxy = st.proxy
if st.retries:
yf.config.network.retries = st.retries
yf.config.locale.lang = st.lang
yf.config.locale.region = st.region
if st.cache_dir:
yf.set_tz_cache_location(st.cache_dir)
if st.cookies_t and st.cookies_y:
try:
yf.Auth.set_login_cookies(st.cookies_t, st.cookies_y)
except Exception:
pass
# Logging.
if st.verbose >= 2:
logging.basicConfig(level=logging.DEBUG)
yf.enable_debug_mode()
elif st.verbose == 1:
logging.basicConfig(level=logging.INFO)
# Console.
console = Console(no_color=st.no_color or st.quiet, quiet=False, stderr=False, soft_wrap=False)
renderers.set_console(console)
def main() -> None:
"""Console-script entry point. Wraps the Typer app with friendly error handling."""
try:
app()
except YFRateLimitError:
print(
"yfin: Yahoo rate-limit (HTTP 429). Your IP has been throttled — wait several minutes, or set --proxy.",
file=sys.stderr,
)
sys.exit(2)
if __name__ == "__main__": # pragma: no cover
main()
+66
View File
@@ -0,0 +1,66 @@
"""Shared CLI plumbing: global options state + helpers used by every command."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Union
import typer
@dataclass
class GlobalState:
"""Snapshot of root-level flags. Stored on Typer context.obj."""
fmt: Optional[str] = None
out: Optional[Path] = None
pretty: Optional[bool] = None
compact: bool = False
columns: Optional[str] = None
limit: Optional[int] = None
proxy: Optional[str] = None
timeout: float = 10.0
retries: int = 0
lang: str = "en-US"
region: str = "US"
cache_dir: Optional[str] = None
config_path: Optional[Path] = None
no_color: bool = False
quiet: bool = False
verbose: int = 0
cookies_t: Optional[str] = None
cookies_y: Optional[str] = None
config_extras: dict = field(default_factory=dict)
def get_state(ctx: typer.Context) -> GlobalState:
return ctx.obj # set in __main__.py root callback
def render_via_state(ctx: typer.Context, value) -> None:
"""Render with the global --format / --out / --pretty / --columns / --limit."""
from . import renderers
st = get_state(ctx)
pretty = None
if st.pretty:
pretty = True
elif st.compact:
pretty = False
renderers.render(
value,
fmt=st.fmt,
out=st.out,
pretty=pretty,
columns=st.columns,
limit=st.limit,
)
def parse_symbols(arg: Union[str, List[str]]) -> List[str]:
"""Accept '"AAPL MSFT"' or 'AAPL,MSFT' or a real list."""
if isinstance(arg, list):
items: List[str] = []
for v in arg:
items.extend(v.replace(",", " ").split())
return [s.upper() for s in items if s]
return [s.upper() for s in arg.replace(",", " ").split() if s]
+1
View File
@@ -0,0 +1 @@
"""Subcommand modules."""
+48
View File
@@ -0,0 +1,48 @@
"""`yfin auth ...` — Yahoo cookie / strategy management."""
from __future__ import annotations
from enum import Enum
import typer
import yfinance as yf
from .._shared import get_state
from .. import config as cfg_mod
class Strategy(str, Enum):
basic = "basic"
csrf = "csrf"
app = typer.Typer(no_args_is_help=True, help="Yahoo cookie / strategy management.")
@app.command("cookies")
def cookies(
ctx: typer.Context,
t: str = typer.Option(..., "--t", help='Yahoo "T" cookie value.'),
y: str = typer.Option(..., "--y", help='Yahoo "Y" cookie value.'),
save: bool = typer.Option(False, "--save/--no-save", help="Persist to ~/.yfin/config.toml."),
):
"""Inject Yahoo login cookies into the shared session."""
yf.Auth.set_login_cookies(t, y)
typer.echo("Cookies set on current session.")
if save:
st = get_state(ctx)
cfg = cfg_mod.load(st.config_path)
cfg.cookies_t = t
cfg.cookies_y = y
p = cfg_mod.save(cfg, st.config_path)
typer.echo(f"Saved to {p}.")
@app.command("strategy")
def strategy(
ctx: typer.Context,
strategy: Strategy = typer.Argument(...),
):
"""Force the cookie-acquisition strategy used by YfData (basic|csrf)."""
from yfinance.data import YfData
YfData()._cookie_strategy = strategy.value
typer.echo(f"Auth strategy set to {strategy.value}.")
+85
View File
@@ -0,0 +1,85 @@
"""`yfin calendars ...` — market-wide event calendars."""
from __future__ import annotations
from typing import Optional
import typer
import yfinance as yf
from .._shared import render_via_state
app = typer.Typer(no_args_is_help=True, help="Market-wide event calendars.")
def _cal(start: Optional[str], end: Optional[str]) -> yf.Calendars:
return yf.Calendars(start=start, end=end)
@app.command("earnings")
def earnings(
ctx: typer.Context,
start: Optional[str] = typer.Option(None, "--start"),
end: Optional[str] = typer.Option(None, "--end"),
market_cap: Optional[float] = typer.Option(None, "--market-cap"),
filter_most_active: bool = typer.Option(True, "--filter-most-active/--no-filter-most-active"),
limit: int = typer.Option(12, "--limit"),
offset: int = typer.Option(0, "--offset"),
force: bool = typer.Option(False, "--force/--no-force"),
):
"""Earnings calendar."""
df = _cal(start, end).get_earnings_calendar(
market_cap=market_cap,
filter_most_active=filter_most_active,
start=start, end=end,
limit=limit, offset=offset, force=force,
)
render_via_state(ctx, df)
@app.command("ipo")
def ipo(
ctx: typer.Context,
start: Optional[str] = typer.Option(None, "--start"),
end: Optional[str] = typer.Option(None, "--end"),
limit: int = typer.Option(12, "--limit"),
offset: int = typer.Option(0, "--offset"),
force: bool = typer.Option(False, "--force/--no-force"),
):
"""IPO calendar."""
df = _cal(start, end).get_ipo_info_calendar(
start=start, end=end, limit=limit, offset=offset, force=force
)
render_via_state(ctx, df)
@app.command("economic")
def economic(
ctx: typer.Context,
start: Optional[str] = typer.Option(None, "--start"),
end: Optional[str] = typer.Option(None, "--end"),
limit: int = typer.Option(12, "--limit"),
offset: int = typer.Option(0, "--offset"),
force: bool = typer.Option(False, "--force/--no-force"),
):
"""Economic events calendar (CPI, FOMC, etc.)."""
df = _cal(start, end).get_economic_events_calendar(
start=start, end=end, limit=limit, offset=offset, force=force
)
render_via_state(ctx, df)
@app.command("splits")
def splits(
ctx: typer.Context,
start: Optional[str] = typer.Option(None, "--start"),
end: Optional[str] = typer.Option(None, "--end"),
limit: int = typer.Option(12, "--limit"),
offset: int = typer.Option(0, "--offset"),
force: bool = typer.Option(False, "--force/--no-force"),
):
"""Upcoming stock-splits calendar."""
df = _cal(start, end).get_splits_calendar(
start=start, end=end, limit=limit, offset=offset, force=force
)
render_via_state(ctx, df)
+105
View File
@@ -0,0 +1,105 @@
"""`yfin config ...` — read / write the yfin config file + in-process yf.config."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
import yfinance as yf
from .._shared import render_via_state, get_state
from .. import config as cfg_mod
app = typer.Typer(no_args_is_help=True, help="yfin / yfinance config.")
@app.command("get")
def get(
ctx: typer.Context,
key: Optional[str] = typer.Argument(None, help='Optional dotted path, e.g. "network.proxy".'),
):
"""Dump the active yf.config tree (defaults + flags applied)."""
raw = json.loads(repr(yf.config))
if key is None:
render_via_state(ctx, raw)
return
val = raw
for part in key.split("."):
if not isinstance(val, dict) or part not in val:
raise typer.BadParameter(f"Key {key!r} not found.")
val = val[part]
render_via_state(ctx, val)
@app.command("set")
def set_cmd(
ctx: typer.Context,
proxy: Optional[str] = typer.Option(None, "--proxy"),
retries: Optional[int] = typer.Option(None, "--retries"),
lang: Optional[str] = typer.Option(None, "--lang"),
region: Optional[str] = typer.Option(None, "--region"),
hide_exceptions: Optional[bool] = typer.Option(None, "--hide-exceptions/--no-hide-exceptions"),
logging_on: Optional[bool] = typer.Option(None, "--logging/--no-logging"),
save: bool = typer.Option(False, "--save/--no-save", help="Persist to ~/.yfin/config.toml."),
):
"""Update yf.config (and optionally persist to ~/.yfin/config.toml)."""
if proxy is not None:
yf.config.network.proxy = proxy or None
if retries is not None:
yf.config.network.retries = retries
if lang is not None:
yf.config.locale.lang = lang
if region is not None:
yf.config.locale.region = region
if hide_exceptions is not None:
yf.config.debug.hide_exceptions = hide_exceptions
if logging_on is not None:
yf.config.debug.logging = logging_on
if save:
st = get_state(ctx)
cfg = cfg_mod.load(st.config_path)
if proxy is not None:
cfg.proxy = proxy or None
if retries is not None:
cfg.retries = retries
if lang is not None:
cfg.lang = lang
if region is not None:
cfg.region = region
p = cfg_mod.save(cfg, st.config_path)
typer.echo(f"Saved to {p}.")
render_via_state(ctx, json.loads(repr(yf.config)))
@app.command("debug-on")
def debug_on(ctx: typer.Context):
"""Enable verbose yfinance logging (yf.enable_debug_mode())."""
yf.enable_debug_mode()
typer.echo("Debug logging enabled.")
@app.command("cache-dir")
def cache_dir(ctx: typer.Context, path: Path = typer.Argument(...)):
"""Point yfinance's SQLite tz/cookie cache at PATH."""
yf.set_tz_cache_location(str(path))
typer.echo(f"Cache directory set to {path}.")
@app.command("clear-cache")
def clear_cache(
ctx: typer.Context,
path: Optional[Path] = typer.Option(None, "--path", help="Directory to remove; default = active cache."),
):
"""Delete the on-disk tz/cookie cache directory."""
import platformdirs
import shutil
target = path or Path(platformdirs.user_cache_dir()) / "py-yfinance"
if target.exists():
shutil.rmtree(target)
typer.echo(f"Removed {target}.")
else:
typer.echo(f"Nothing to remove at {target}.")
+70
View File
@@ -0,0 +1,70 @@
"""`yfin download ...` — top-level multi-ticker history shortcut."""
from __future__ import annotations
from enum import Enum
from typing import List, Optional
import typer
import yfinance as yf
from .._shared import parse_symbols, render_via_state, get_state
class GroupBy(str, Enum):
ticker = "ticker"
column = "column"
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"
def download(
ctx: typer.Context,
symbols: List[str] = typer.Argument(..., help="Symbols (space- or comma-separated)."),
start: Optional[str] = typer.Option(None, "--start"),
end: Optional[str] = typer.Option(None, "--end"),
period: Optional[Period] = typer.Option(None, "--period"),
interval: Interval = typer.Option(Interval.d1, "--interval"),
group_by: GroupBy = typer.Option(GroupBy.column, "--group-by"),
actions: bool = typer.Option(False, "--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"),
prepost: bool = typer.Option(False, "--prepost/--no-prepost"),
threads: bool = typer.Option(True, "--threads/--no-threads"),
ignore_tz: Optional[bool] = typer.Option(None, "--ignore-tz/--no-ignore-tz"),
rounding: bool = typer.Option(False, "--rounding/--no-rounding"),
progress: bool = typer.Option(True, "--progress/--no-progress"),
multi_level_index: bool = typer.Option(True, "--multi-level-index/--no-multi-level-index"),
):
"""Batch OHLCV download (parallel)."""
st = get_state(ctx)
tickers = parse_symbols(symbols)
kw = dict(
tickers=tickers,
start=start, end=end,
interval=interval.value,
group_by=group_by.value,
actions=actions, auto_adjust=auto_adjust, back_adjust=back_adjust,
repair=repair, keepna=keepna, prepost=prepost,
threads=threads,
rounding=rounding,
progress=progress and not st.quiet,
timeout=st.timeout,
multi_level_index=multi_level_index,
)
if period and not (start or end):
kw["period"] = period.value
if ignore_tz is not None:
kw["ignore_tz"] = ignore_tz
df = yf.download(**kw)
render_via_state(ctx, df)
+50
View File
@@ -0,0 +1,50 @@
"""`yfin industry ...` — industry aggregates."""
from __future__ import annotations
from enum import Enum
from typing import Optional
import typer
import yfinance as yf
from .._shared import render_via_state
class Attribute(str, Enum):
overview = "overview"
top_companies = "top_companies"
top_performing_companies = "top_performing_companies"
top_growth_companies = "top_growth_companies"
research_reports = "research_reports"
sector_key = "sector_key"
sector_name = "sector_name"
app = typer.Typer(no_args_is_help=True, help="Industry aggregates.")
@app.command("info")
def industry(
ctx: typer.Context,
key: str = typer.Argument(..., help='Industry slug (e.g. "software-infrastructure").'),
attribute: Optional[Attribute] = typer.Option(None, "--attribute"),
region: str = typer.Option("US", "--region"),
):
"""Industry aggregates (top performing / growth companies)."""
i = yf.Industry(key, region=region)
if attribute is None:
out = {
"key": i.key,
"name": i.name,
"symbol": i.symbol,
"sector_key": i.sector_key,
"sector_name": i.sector_name,
"overview": i.overview,
"top_companies": i.top_companies,
"top_performing_companies": i.top_performing_companies,
"top_growth_companies": i.top_growth_companies,
"research_reports": i.research_reports,
}
else:
out = getattr(i, attribute.value)
render_via_state(ctx, out)
+105
View File
@@ -0,0 +1,105 @@
"""`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,
)
+32
View File
@@ -0,0 +1,32 @@
"""`yfin lookup ...` — fuzzy symbol lookup."""
from __future__ import annotations
from enum import Enum
import typer
import yfinance as yf
from .._shared import render_via_state, get_state
class LookupType(str, Enum):
all = "all"; stock = "stock"; mutualfund = "mutualfund"; etf = "etf"
index = "index"; future = "future"; currency = "currency"; cryptocurrency = "cryptocurrency"
app = typer.Typer(no_args_is_help=True, help="Fuzzy symbol lookup.")
@app.command("run")
def lookup(
ctx: typer.Context,
query: str = typer.Argument(...),
type: LookupType = typer.Option(LookupType.all, "--type"),
count: int = typer.Option(25, "--count"),
raise_errors: bool = typer.Option(True, "--raise-errors/--no-raise-errors"),
):
"""Resolve a query to candidate symbols (one asset class)."""
st = get_state(ctx)
L = yf.Lookup(query, timeout=st.timeout, raise_errors=raise_errors)
fn = getattr(L, f"get_{type.value}")
render_via_state(ctx, fn(count=count))
+36
View File
@@ -0,0 +1,36 @@
"""`yfin market ...` — region-level overview / status."""
from __future__ import annotations
from enum import Enum
import typer
import yfinance as yf
from .._shared import render_via_state, get_state
class Region(str, Enum):
US = "US"; GB = "GB"; ASIA = "ASIA"; EUROPE = "EUROPE"
RATES = "RATES"; COMMODITIES = "COMMODITIES"
CURRENCIES = "CURRENCIES"; CRYPTOCURRENCIES = "CRYPTOCURRENCIES"
app = typer.Typer(no_args_is_help=True, help="Region-level market overview / status.")
@app.command("summary")
def summary(ctx: typer.Context, region: Region = typer.Argument(...)):
"""All exchanges/indices Yahoo tracks for this region."""
st = get_state(ctx)
render_via_state(ctx, yf.Market(region.value, timeout=st.timeout).summary)
@app.command("status")
def status(ctx: typer.Context, region: Region = typer.Argument(...)):
"""Open/closed + open/close times + tz."""
st = get_state(ctx)
s = yf.Market(region.value, timeout=st.timeout).status
# Status carries datetime objects; coerce for JSON safety.
if isinstance(s, dict):
s = {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in s.items()}
render_via_state(ctx, s)
+132
View File
@@ -0,0 +1,132 @@
"""`yfin screen ...` — predefined + custom market screens."""
from __future__ import annotations
import json
import sys
from enum import Enum
from pathlib import Path
from typing import List, Optional
import typer
import yfinance as yf
from .._shared import render_via_state
class QueryType(str, Enum):
equity = "equity"; mutualfund = "mutualfund"; etf = "etf"
_QUERY_CLASS = {
QueryType.equity: yf.EquityQuery,
QueryType.mutualfund: yf.FundQuery,
QueryType.etf: yf.ETFQuery,
}
app = typer.Typer(no_args_is_help=True, help="Predefined + custom market screens.")
@app.command("list-predefined")
def list_predefined(
ctx: typer.Context,
include_bodies: bool = typer.Option(False, "--include-bodies/--no-include-bodies"),
):
"""Show available predefined Yahoo screener names."""
if include_bodies:
out = {k: {"sortField": v.get("sortField"), "sortType": v.get("sortType"),
"query": str(v["query"])} for k, v in yf.PREDEFINED_SCREENER_QUERIES.items()}
else:
out = sorted(yf.PREDEFINED_SCREENER_QUERIES.keys())
render_via_state(ctx, out)
@app.command("predefined")
def predefined(
ctx: typer.Context,
name: str = typer.Argument(..., help="Predefined screener key (e.g. day_gainers)."),
count: int = typer.Option(25, "--count"),
offset: Optional[int] = typer.Option(None, "--offset"),
sort_field: Optional[str] = typer.Option(None, "--sort-field"),
sort_asc: Optional[bool] = typer.Option(None, "--sort-asc/--sort-desc"),
):
"""Run a predefined Yahoo screener."""
if name not in yf.PREDEFINED_SCREENER_QUERIES:
raise typer.BadParameter(
f"Unknown predefined screener {name!r}. Use `yfin screen list-predefined`."
)
out = yf.screen(
name, count=count, offset=offset,
sortField=sort_field, sortAsc=sort_asc,
)
render_via_state(ctx, out)
@app.command("run")
def run(
ctx: typer.Context,
query: str = typer.Option(
..., "--query", "-Q",
help='JSON QueryBase tree, "@file.json", or "-" to read stdin.',
),
type: QueryType = typer.Option(QueryType.equity, "--type"),
size: int = typer.Option(100, "--size"),
offset: int = typer.Option(0, "--offset"),
sort_field: Optional[str] = typer.Option(None, "--sort-field"),
sort_asc: bool = typer.Option(False, "--sort-asc/--sort-desc"),
):
"""Run a custom screen built from a QueryBase tree."""
raw = _load_query(query)
q = _build_query(type, raw)
out = yf.screen(
q, size=size, offset=offset,
sortField=sort_field, sortAsc=sort_asc,
)
render_via_state(ctx, out)
@app.command("build")
def build(
ctx: typer.Context,
type: QueryType = typer.Option(..., "--type"),
operator: str = typer.Option(..., "--operator"),
operands: str = typer.Option(..., "--operands", help='JSON array. For and/or, items are nested {operator, operands} dicts; for value ops, items are field+scalar.'),
):
"""Build and emit a QueryBase serialization (no Yahoo call)."""
cls = _QUERY_CLASS[type]
ops = json.loads(operands)
if not isinstance(ops, list):
raise typer.BadParameter("--operands must be a JSON array.")
op_up = operator.upper()
if op_up in {"AND", "OR"}:
wrapped = [_build_query(type, item) if isinstance(item, dict) else item for item in ops]
q = cls(operator, wrapped)
else:
q = cls(operator, ops)
render_via_state(ctx, {"type": type.value, "query": q.to_dict()})
def _load_query(query: str) -> dict:
if query == "-":
text = sys.stdin.read()
elif query.startswith("@"):
text = Path(query[1:]).read_text()
else:
text = query
raw = json.loads(text)
if "query" in raw and isinstance(raw["query"], dict):
raw = raw["query"]
return raw
def _build_query(type: QueryType, raw: dict):
cls = _QUERY_CLASS[type]
op = raw["operator"]
operands = raw.get("operands") or raw.get("operand") or []
built: List = []
for o in operands:
if isinstance(o, dict) and "operator" in o:
built.append(_build_query(type, o))
else:
built.append(o)
return cls(op, built)
+44
View File
@@ -0,0 +1,44 @@
"""`yfin search ...` — Yahoo Finance keyword search."""
from __future__ import annotations
import typer
import yfinance as yf
from .._shared import render_via_state, get_state
app = typer.Typer(no_args_is_help=True, help="Yahoo Finance keyword search.")
@app.command("run")
def search(
ctx: typer.Context,
query: str = typer.Argument(..., help="Search query (ticker, partial ticker or company name)."),
max_results: int = typer.Option(8, "--max-results"),
news_count: int = typer.Option(8, "--news-count"),
lists_count: int = typer.Option(8, "--lists-count"),
include_cb: bool = typer.Option(True, "--include-cb/--no-include-cb"),
include_nav_links: bool = typer.Option(False, "--include-nav-links/--no-include-nav-links"),
include_research: bool = typer.Option(False, "--include-research/--no-include-research"),
include_cultural_assets: bool = typer.Option(False, "--include-cultural-assets/--no-include-cultural-assets"),
fuzzy: bool = typer.Option(False, "--fuzzy/--no-fuzzy", help="Alias for enable_fuzzy_query."),
recommended: int = typer.Option(8, "--recommended"),
raise_errors: bool = typer.Option(True, "--raise-errors/--no-raise-errors"),
):
"""Run a search and dump quotes + news + lists + research + nav."""
st = get_state(ctx)
s = yf.Search(
query,
max_results=max_results,
news_count=news_count,
lists_count=lists_count,
include_cb=include_cb,
include_nav_links=include_nav_links,
include_research=include_research,
include_cultural_assets=include_cultural_assets,
enable_fuzzy_query=fuzzy,
recommended=recommended,
timeout=st.timeout,
raise_errors=raise_errors,
)
render_via_state(ctx, s.all)
+71
View File
@@ -0,0 +1,71 @@
"""`yfin sector ...` — sector aggregates."""
from __future__ import annotations
from enum import Enum
from typing import Optional
import typer
import yfinance as yf
from .._shared import render_via_state
SECTOR_KEYS = [
"basic-materials",
"communication-services",
"consumer-cyclical",
"consumer-defensive",
"energy",
"financial-services",
"healthcare",
"industrials",
"real-estate",
"technology",
"utilities",
]
class Attribute(str, Enum):
overview = "overview"
top_companies = "top_companies"
top_etfs = "top_etfs"
top_mutual_funds = "top_mutual_funds"
industries = "industries"
research_reports = "research_reports"
name = "name"
symbol = "symbol"
app = typer.Typer(no_args_is_help=True, help="Sector aggregates.")
@app.command("list")
def list_sectors(ctx: typer.Context):
"""Print the 11 valid Yahoo sector keys (no Yahoo call)."""
render_via_state(ctx, SECTOR_KEYS)
@app.command("info")
def sector(
ctx: typer.Context,
key: str = typer.Argument(..., help="Sector slug (use `yfin sector list` to enumerate)."),
attribute: Optional[Attribute] = typer.Option(None, "--attribute"),
region: str = typer.Option("US", "--region"),
):
"""Sector aggregates (top companies / ETFs / mutual funds / industries)."""
s = yf.Sector(key, region=region)
if attribute is None:
out = {
"key": s.key,
"name": s.name,
"symbol": s.symbol,
"overview": s.overview,
"top_companies": s.top_companies,
"top_etfs": s.top_etfs,
"top_mutual_funds": s.top_mutual_funds,
"industries": s.industries,
"research_reports": s.research_reports,
}
else:
out = getattr(s, attribute.value)
render_via_state(ctx, out)
+371
View File
@@ -0,0 +1,371 @@
"""`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)
+53
View File
@@ -0,0 +1,53 @@
"""`yfin tickers ...` — Tickers-collection operations."""
from __future__ import annotations
from typing import List, Optional
import typer
import yfinance as yf
from .._shared import parse_symbols, render_via_state, get_state
from .download import GroupBy, Period, Interval
app = typer.Typer(no_args_is_help=True, help="Tickers-collection operations.")
@app.command("history")
def history(
ctx: typer.Context,
symbols: List[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"),
repair: bool = typer.Option(False, "--repair/--no-repair"),
threads: bool = typer.Option(True, "--threads/--no-threads"),
group_by: GroupBy = typer.Option(GroupBy.column, "--group-by"),
progress: bool = typer.Option(True, "--progress/--no-progress"),
):
"""Multi-symbol history via the Tickers collection."""
st = get_state(ctx)
tickers = parse_symbols(symbols)
t = yf.Tickers(" ".join(tickers))
kw = dict(
interval=interval.value, start=start, end=end,
prepost=prepost, actions=actions, auto_adjust=auto_adjust,
repair=repair, threads=threads, group_by=group_by.value,
progress=progress and not st.quiet, timeout=st.timeout,
)
if period and not (start or end):
kw["period"] = period.value
df = t.history(**kw)
render_via_state(ctx, df)
@app.command("news")
def news(ctx: typer.Context, symbols: List[str] = typer.Argument(...)):
"""Per-symbol news dict."""
tickers = parse_symbols(symbols)
out = yf.Tickers(" ".join(tickers)).news()
render_via_state(ctx, out)
+98
View File
@@ -0,0 +1,98 @@
"""~/.yfin/config.toml loader. Defaults are also flags on the root callback."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
try:
import tomllib # py311+
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib # type: ignore
DEFAULT_PATH = Path.home() / ".yfin" / "config.toml"
@dataclass
class AppConfig:
proxy: Optional[str] = None
retries: int = 0
lang: str = "en-US"
region: str = "US"
timeout: float = 10.0
cache_dir: Optional[str] = None
default_format: Optional[str] = None
cookies_t: Optional[str] = None
cookies_y: Optional[str] = None
extras: dict = field(default_factory=dict)
def load(path: Optional[Path] = None) -> AppConfig:
p = Path(path) if path else DEFAULT_PATH
if not p.exists():
return AppConfig()
data = tomllib.loads(p.read_text())
cfg = AppConfig()
network = data.get("network", {})
locale = data.get("locale", {})
cache = data.get("cache", {})
auth = data.get("auth", {})
output = data.get("output", {})
if "proxy" in network:
cfg.proxy = network["proxy"]
if "retries" in network:
cfg.retries = int(network["retries"])
if "timeout" in network:
cfg.timeout = float(network["timeout"])
if "lang" in locale:
cfg.lang = locale["lang"]
if "region" in locale:
cfg.region = locale["region"]
if "dir" in cache:
cfg.cache_dir = cache["dir"]
if "default_format" in output:
cfg.default_format = output["default_format"]
if "cookie_t" in auth:
cfg.cookies_t = auth["cookie_t"]
if "cookie_y" in auth:
cfg.cookies_y = auth["cookie_y"]
cfg.extras = {k: v for k, v in data.items() if k not in {"network", "locale", "cache", "auth", "output"}}
return cfg
def save(cfg: AppConfig, path: Optional[Path] = None) -> Path:
p = Path(path) if path else DEFAULT_PATH
p.parent.mkdir(parents=True, exist_ok=True)
lines = []
if cfg.proxy is not None or cfg.retries or cfg.timeout != 10.0:
lines.append("[network]")
if cfg.proxy is not None:
lines.append(f'proxy = "{cfg.proxy}"')
if cfg.retries:
lines.append(f"retries = {cfg.retries}")
if cfg.timeout != 10.0:
lines.append(f"timeout = {cfg.timeout}")
lines.append("")
if cfg.lang != "en-US" or cfg.region != "US":
lines.append("[locale]")
lines.append(f'lang = "{cfg.lang}"')
lines.append(f'region = "{cfg.region}"')
lines.append("")
if cfg.cache_dir:
lines.append("[cache]")
lines.append(f'dir = "{cfg.cache_dir}"')
lines.append("")
if cfg.default_format:
lines.append("[output]")
lines.append(f'default_format = "{cfg.default_format}"')
lines.append("")
if cfg.cookies_t or cfg.cookies_y:
lines.append("[auth]")
if cfg.cookies_t:
lines.append(f'cookie_t = "{cfg.cookies_t}"')
if cfg.cookies_y:
lines.append(f'cookie_y = "{cfg.cookies_y}"')
lines.append("")
p.write_text("\n".join(lines).rstrip() + "\n")
return p
+321
View File
@@ -0,0 +1,321 @@
"""Output rendering: --format dispatch + --out writing.
Commands return raw Python values (DataFrame / Series / dict / list / NamedTuple).
This module turns them into bytes / text on the right sink.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Optional
import pandas as pd
import yaml
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
_EXT_MAP = {
".json": "json",
".ndjson": "ndjson",
".jsonl": "ndjson",
".csv": "csv",
".tsv": "tsv",
".parquet": "parquet",
".pq": "parquet",
".yaml": "yaml",
".yml": "yaml",
}
_VALID = {"table", "json", "ndjson", "csv", "tsv", "parquet", "yaml"}
# Module-level console — re-bound by the root app so --no-color/--quiet propagate.
_console: Optional[Console] = None
def get_console() -> Console:
global _console
if _console is None:
_console = Console()
return _console
def set_console(console: Console) -> None:
global _console
_console = console
def resolve_format(fmt: Optional[str], out: Optional[Path]) -> str:
"""Choose final format. Explicit --format wins; otherwise infer from --out; else default."""
if fmt:
if fmt not in _VALID:
raise ValueError(f"Unknown --format {fmt!r}; valid: {sorted(_VALID)}")
return fmt
if out:
return _EXT_MAP.get(out.suffix.lower(), "json")
return "table" if sys.stdout.isatty() else "json"
def render(
value: Any,
fmt: Optional[str] = None,
out: Optional[Path] = None,
pretty: Optional[bool] = None,
columns: Optional[str] = None,
limit: Optional[int] = None,
) -> None:
"""Render `value` to stdout or to `out`. Mutates nothing else."""
if value is None:
get_console().print("[dim](no result)[/dim]" if sys.stdout.isatty() else "")
return
resolved = resolve_format(fmt, out)
# Apply --columns / --limit on tabular values before rendering.
if isinstance(value, pd.DataFrame):
if columns:
cols = [c.strip() for c in columns.split(",")]
keep = [c for c in cols if c in value.columns]
if keep:
value = value[keep]
if limit is not None:
value = value.head(limit)
elif isinstance(value, pd.Series):
if limit is not None:
value = value.head(limit)
elif isinstance(value, list) and limit is not None:
value = value[:limit]
if out:
out = Path(out)
out.parent.mkdir(parents=True, exist_ok=True)
if resolved == "table":
_render_table(value)
elif resolved == "json":
_write_text(_to_json(value, pretty=pretty if pretty is not None else True), out)
elif resolved == "ndjson":
_write_text(_to_ndjson(value), out)
elif resolved == "csv":
_write_text(_to_csv(value, sep=","), out)
elif resolved == "tsv":
_write_text(_to_csv(value, sep="\t"), out)
elif resolved == "parquet":
_write_parquet(value, out)
elif resolved == "yaml":
_write_text(_to_yaml(value), out)
else:
raise ValueError(f"Unsupported format: {resolved}")
# ---------------------------------------------------------------------------- #
# Format implementations
# ---------------------------------------------------------------------------- #
def _to_json(value: Any, *, pretty: bool) -> str:
indent = 2 if pretty else None
if isinstance(value, pd.DataFrame):
# orient='split' keeps index + columns + data sections, round-trippable.
return value.to_json(orient="split", date_format="iso", indent=indent or 0)
if isinstance(value, pd.Series):
return value.to_json(orient="split", date_format="iso", indent=indent or 0)
if hasattr(value, "_asdict"): # NamedTuple
value = {k: _coerce(v) for k, v in value._asdict().items()}
return json.dumps(_coerce(value), default=str, indent=indent, ensure_ascii=False)
def _to_ndjson(value: Any) -> str:
if isinstance(value, pd.DataFrame):
return value.to_json(orient="records", lines=True, date_format="iso")
if isinstance(value, pd.Series):
return value.to_json(orient="records", lines=True, date_format="iso")
if isinstance(value, list):
return "\n".join(json.dumps(_coerce(x), default=str, ensure_ascii=False) for x in value)
return json.dumps(_coerce(value), default=str, ensure_ascii=False)
def _to_csv(value: Any, sep: str) -> str:
if isinstance(value, pd.DataFrame):
return value.to_csv(sep=sep)
if isinstance(value, pd.Series):
return value.to_csv(sep=sep, header=True)
if isinstance(value, list) and value and isinstance(value[0], dict):
return pd.DataFrame(value).to_csv(sep=sep, index=False)
raise TypeError(f"--format csv/tsv only supports DataFrames / Series / list[dict] (got {type(value).__name__})")
def _write_parquet(value: Any, out: Optional[Path]) -> None:
if not isinstance(value, pd.DataFrame):
if isinstance(value, pd.Series):
value = value.to_frame()
elif isinstance(value, list) and value and isinstance(value[0], dict):
value = pd.DataFrame(value)
else:
raise TypeError("--format parquet only supports DataFrames / Series / list[dict]")
if out is None:
raise ValueError("--format parquet requires --out")
value.to_parquet(out)
get_console().print(f"[green]wrote[/green] {out} ({len(value):,} rows × {len(value.columns)} cols)", highlight=False)
def _to_yaml(value: Any) -> str:
if isinstance(value, pd.DataFrame):
value = value.reset_index().to_dict(orient="records")
elif isinstance(value, pd.Series):
value = value.to_dict()
return yaml.safe_dump(_coerce(value), sort_keys=False, allow_unicode=True)
def _write_text(text: str, out: Optional[Path]) -> None:
if out:
out.write_text(text)
get_console().print(f"[green]wrote[/green] {out} ({len(text):,} bytes)", highlight=False)
else:
# Use plain print so piping isn't corrupted by Rich markup.
print(text)
# ---------------------------------------------------------------------------- #
# Rich table rendering
# ---------------------------------------------------------------------------- #
def _render_table(value: Any) -> None:
c = get_console()
if isinstance(value, pd.DataFrame):
c.print(_dataframe_to_table(value))
elif isinstance(value, pd.Series):
c.print(_series_to_table(value))
elif hasattr(value, "_asdict"): # NamedTuple (e.g. option_chain)
for name, sub in value._asdict().items():
c.print(Panel(_render_any_to_renderable(sub), title=str(name)))
elif isinstance(value, dict):
c.print(_dict_to_table(value))
elif isinstance(value, list):
c.print(_list_to_renderable(value))
else:
c.print(value)
def _render_any_to_renderable(value: Any):
if isinstance(value, pd.DataFrame):
return _dataframe_to_table(value)
if isinstance(value, pd.Series):
return _series_to_table(value)
if isinstance(value, dict):
return _dict_to_table(value)
if isinstance(value, list):
return _list_to_renderable(value)
return str(value)
def _dataframe_to_table(df: pd.DataFrame, max_rows: int = 200) -> Table:
t = Table(show_header=True, header_style="bold cyan", row_styles=["", "dim"])
index_name = df.index.name or "index"
t.add_column(str(index_name), style="bold")
for col in df.columns:
t.add_column(str(col))
n = min(len(df), max_rows)
for idx, row in df.head(n).iterrows():
t.add_row(_fmt(idx), *[_fmt(v) for v in row.values])
if len(df) > n:
t.caption = f"[dim]… {len(df) - n:,} more rows omitted[/dim]"
return t
def _series_to_table(s: pd.Series) -> Table:
t = Table(show_header=True, header_style="bold cyan")
t.add_column(str(s.index.name or "index"), style="bold")
t.add_column(str(s.name or "value"))
for idx, val in s.items():
t.add_row(_fmt(idx), _fmt(val))
return t
def _dict_to_table(d: dict) -> Any:
# If values are themselves complex, fall back to a Tree for readability.
if any(isinstance(v, (dict, list, pd.DataFrame, pd.Series)) for v in d.values()):
tree = Tree("[bold cyan]dict[/bold cyan]")
_dict_to_tree(d, tree)
return tree
t = Table(show_header=True, header_style="bold cyan")
t.add_column("key", style="bold")
t.add_column("value")
for k, v in d.items():
t.add_row(str(k), _fmt(v))
return t
def _dict_to_tree(d: dict, parent: Tree) -> None:
for k, v in d.items():
if isinstance(v, dict):
sub = parent.add(f"[bold]{k}[/bold]")
_dict_to_tree(v, sub)
elif isinstance(v, pd.DataFrame):
parent.add(f"[bold]{k}[/bold] [dim](DataFrame {v.shape[0]}×{v.shape[1]})[/dim]")
elif isinstance(v, list):
sub = parent.add(f"[bold]{k}[/bold] [dim](list × {len(v)})[/dim]")
for i, item in enumerate(v[:10]):
sub.add(_fmt(item))
if len(v) > 10:
sub.add(f"[dim]… {len(v) - 10} more[/dim]")
else:
parent.add(f"[bold]{k}[/bold]: {_fmt(v)}")
def _list_to_renderable(lst: list) -> Any:
if not lst:
return "[dim](empty list)[/dim]"
if all(isinstance(x, dict) for x in lst):
try:
return _dataframe_to_table(pd.DataFrame(lst))
except Exception:
pass
# Bullet list
return "\n".join(f"{_fmt(x)}" for x in lst[:200])
# ---------------------------------------------------------------------------- #
# Coercion / formatting helpers
# ---------------------------------------------------------------------------- #
def _fmt(v: Any) -> str:
if v is None:
return "[dim]—[/dim]"
if isinstance(v, float):
if v != v: # NaN
return "[dim]NaN[/dim]"
if abs(v) >= 1e9:
return f"{v:,.4g}"
if abs(v) >= 1:
return f"{v:,.4f}".rstrip("0").rstrip(".")
return f"{v:.6g}"
if isinstance(v, int):
return f"{v:,}"
if isinstance(v, pd.Timestamp):
return v.isoformat()
s = str(v)
if len(s) > 200:
s = s[:197] + ""
return s
def _coerce(value: Any) -> Any:
"""Recursively convert pandas / numpy / Timestamp into JSON-safe Python."""
if isinstance(value, pd.DataFrame):
return json.loads(value.to_json(orient="split", date_format="iso"))
if isinstance(value, pd.Series):
return json.loads(value.to_json(orient="split", date_format="iso"))
if isinstance(value, pd.Timestamp):
return value.isoformat()
if isinstance(value, dict):
return {str(k): _coerce(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_coerce(x) for x in value]
if hasattr(value, "item"): # numpy scalar
try:
return value.item()
except Exception:
return str(value)
return value
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "yfinance" %}
{% set version = "1.4.0" %}
{% set version = "1.4.1" %}
package:
name: "{{ name|lower }}"
+2
View File
@@ -324,6 +324,8 @@ class TestTickerHistory(unittest.TestCase):
interval=interval, progress=False)
self.assertIsInstance(data, pd.DataFrame, "data has wrong type")
self.assertFalse(data.empty, "data is empty")
expected_name = 'Datetime' if interval[-1] in ('m', 'h') else 'Date'
self.assertEqual(data.index.name, expected_name)
if ignore_tz:
self.assertIsNone(data.index.tz)
else:
+2632
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -214,7 +214,7 @@ def _download_impl(ctx, tickers, start=None, end=None, actions=False, threads=Tr
keys=ctx.dfs.keys(), names=['Ticker', 'Price'])
data.rename(columns=ctx.isins, inplace=True)
if group_by == 'column':
if group_by == 'column' and isinstance(data.columns, _pd.MultiIndex):
data.columns = data.columns.swaplevel(0, 1)
data.sort_index(level=0, axis=1, inplace=True)
@@ -240,11 +240,12 @@ def reindex_dfs(dfs, ignore_tz):
if (dfs[tkr] is not None) and (not dfs[tkr].empty):
dfs[tkr].index = dfs[tkr].index.tz_convert(tz_mode)
all_indices = set()
idx = None
for df in dfs.values():
all_indices.update(df.index)
idx = sorted(all_indices)
idx = _pd.to_datetime(idx)
if df is not None and not df.empty:
idx = df.index if idx is None else idx.union(df.index)
if idx is None:
idx = _pd.DatetimeIndex([])
for key, df in dfs.items():
dfs[key] = df.reindex(idx)
+1 -1
View File
@@ -1 +1 @@
version = "1.4.0"
version = "1.4.1"