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>
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Python / uv
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
"""yfin — single-binary CLI for yfinance."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Subcommand modules."""
|
||||||
@@ -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}.")
|
||||||
@@ -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)
|
||||||
@@ -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}.")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+2632
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user