Analysts, DevOps engineers, and hobbyist investors frequently need to pull historical stock price data for a handful of tickers to run quick comparisons, b
Analysts, DevOps engineers, and hobbyist investors frequently need to pull historical stock price data for a handful of tickers to run quick comparisons, build dashboards, or feed into a backtesting pipeline. Manually visiting Yahoo Finance and downloading CSVs one ticker at a time is tedious and error-prone, especially when you need consistent periods, adjusted prices, and a clean tabular output.
This script solves that by wrapping yfinance in a small, scriptable CLI. You provide a comma-separated list of tickers, a period (e.g. 1y, 6mo, 5d), and an output path. The script fetches historical OHLCV data for each ticker, concatenates it into a single pandas DataFrame, writes it to CSV, and optionally renders a closing-price chart. It handles network failures, invalid tickers, and missing data gracefully so it can be safely scheduled in a cron job or CI pipeline.
The script follows a simple linear data flow: parse args → fetch data → combine → persist → visualize. Each ticker is downloaded independently via yfinance.download() so one bad symbol doesn't poison the whole batch; failures are logged and skipped. The per-ticker frames are annotated with a Ticker column and stacked with pd.concat(), producing a tidy long-format DataFrame that's easy to filter and group later.
Design decisions worth noting: (1) yfinance.download() is called with auto_adjust=True so splits and dividends are baked into prices — the right default for analysis. (2) The --plot flag uses pandas' built-in matplotlib backend to avoid extra dependencies. (3) Configuration is layered: CLI args override environment variables, which override hardcoded defaults — a standard pattern for 12-factor-style tools.
#!/usr/bin/env python3
"""
Yahoo Finance Stock Data Fetcher
Fetch historical OHLCV data for one or more tickers from Yahoo Finance,
save the combined result to CSV, and optionally render a closing-price chart.
Usage:
python3 stock_fetch.py --tickers AAPL,MSFT,GOOG --period 1y --output stocks.csv --plot
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from pathlib import Path
import pandas as pd
import yfinance as yf
# ---------------------------------------------------------------------------
# Configuration (environment variables with sensible defaults)
# ---------------------------------------------------------------------------
DEFAULT_PERIOD: str = os.environ.get("STOCK_DEFAULT_PERIOD", "1y")
DEFAULT_INTERVAL: str = os.environ.get("STOCK_DEFAULT_INTERVAL", "1d")
DEFAULT_OUTPUT: str = os.environ.get("STOCK_DEFAULT_OUTPUT", "stocks.csv")
LOG_LEVEL: str = os.environ.get("STOCK_LOG_LEVEL", "INFO").upper()
# Valid values accepted by Yahoo Finance; we validate up front to fail fast.
VALID_PERIODS = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"}
VALID_INTERVALS = {"1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo", "3mo"}
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("stock_fetch")
# ---------------------------------------------------------------------------
# Core logic
# ---------------------------------------------------------------------------
def parse_tickers(raw: str) -> list[str]:
"""Split a comma-separated ticker string into a deduplicated uppercase list."""
tickers = [t.strip().upper() for t in raw.split(",") if t.strip()]
if not tickers:
raise ValueError("No tickers provided. Use --tickers AAPL,MSFT,...")
# Preserve order while removing duplicates.
return list(dict.fromkeys(tickers))
def fetch_ticker(ticker: str, period: str, interval: str) -> pd.DataFrame | None:
"""
Download historical data for a single ticker.
Returns a DataFrame with a 'Ticker' column, or None if the download fails
or Yahoo returns an empty frame (typically an invalid symbol).
"""
log.info("Fetching %s (period=%s, interval=%s)", ticker, period, interval)
try:
df = yf.download(
ticker,
period=period,
interval=interval,
auto_adjust=True, # Adjust for splits/dividends — right default for analysis.
progress=False, # Silence yfinance's tqdm bar; we log ourselves.
threads=False, # Single-threaded keeps logs deterministic.
)
except Exception as exc: # yfinance can raise a variety of network errors.
log.error("Failed to download %s: %s", ticker, exc)
return None
if df is None or df.empty:
log.warning("No data returned for %s — skipping (invalid symbol or delisted?)", ticker)
return None
# yfinance sometimes returns a MultiIndex column set even for one ticker.
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
df = df.reset_index()
df["Ticker"] = ticker
return df
def fetch_all(tickers: list[str], period: str, interval: str) -> pd.DataFrame:
"""Fetch every ticker and concatenate the results into one long DataFrame."""
frames: list[pd.DataFrame] = []
for ticker in tickers:
frame = fetch_ticker(ticker, period, interval)
if frame is not None:
frames.append(frame)
if not frames:
raise RuntimeError("No data could be fetched for any of the requested tickers.")
combined = pd.concat(frames, ignore_index=True)
# Sort so CSV output is deterministic and easy to diff.
sort_cols = [c for c in ("Ticker", "Date") if c in combined.columns]
if sort_cols:
combined = combined.sort_values(sort_cols).reset_index(drop=True)
return combined
def save_csv(df: pd.DataFrame, output_path: Path) -> None:
"""Write the DataFrame to CSV, creating parent directories as needed."""
output_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(output_path, index=False)
log.info("Wrote %d rows to %s", len(df), output_path)
def plot_closes(df: pd.DataFrame) -> None:
"""Render a closing-price line chart, one series per ticker."""
if "Close" not in df.columns or "Date" not in df.columns:
log.warning("Cannot plot: missing 'Date' or 'Close' column.")
return
try:
import matplotlib.pyplot as plt # Imported lazily so --plot is optional.
except ImportError:
log.error("matplotlib is required for --plot. Install with: pip install matplotlib")
return
pivot = df.pivot_table(index="Date", columns="Ticker", values="Close", aggfunc="last")
ax = pivot.plot(figsize=(11, 6), linewidth=1.6)
ax.set_title("Closing Price")
ax.set_xlabel("Date")
ax.set_ylabel("Price (adjusted)")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Fetch historical stock data from Yahoo Finance.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--tickers",
required=True,
help="Comma-separated ticker symbols, e.g. AAPL,MSFT,GOOG",
)
parser.add_argument(
"--period",
default=DEFAULT_PERIOD,
choices=sorted(VALID_PERIODS),
help="Lookback window.",
)
parser.add_argument(
"--interval",
default=DEFAULT_INTERVAL,
choices=sorted(VALID_INTERVALS),
help="Sampling interval between data points.",
)
parser.add_argument(
"--output",
default=DEFAULT_OUTPUT,
help="Path to the output CSV file.",
)
parser.add_argument(
"--plot",
action="store_true",
help="Render a closing-price chart after saving.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
tickers = parse_tickers(args.tickers)
except ValueError as exc:
log.error("%s", exc)
return 2
log.info("Requested tickers: %s", ", ".join(tickers))
try:
df = fetch_all(tickers, args.period, args.interval)
except RuntimeError as exc:
log.error("%s", exc)
return 1
save_csv(df, Path(args.output))
if args.plot:
plot_closes(df)
return 0
if __name__ == "__main__":
sys.exit(main())
| Setting | Env var | Default | Notes |
|---|---|---|---|
| Default period | STOCK_DEFAULT_PERIOD |
1y |
Used when --period is not passed. Must be one of the values in VALID_PERIODS. |
| Default interval | STOCK_DEFAULT_INTERVAL |
1d |
Used when --interval is not passed. Intraday intervals (1m, 5m, …) only work for short periods on Yahoo. |
| Default output | STOCK_DEFAULT_OUTPUT |
stocks.csv |
Used when --output is not passed. Parent directories are created automatically. |
| Log level | STOCK_LOG_LEVEL |
INFO |
Set to DEBUG for verbose output, WARNING to quiet the script in cron. |
CLI flags always override environment variables, which override the hardcoded defaults — the standard precedence for scriptable tools.
1. Fetch one year of daily data for three tickers and save to CSV:
$ python3 stock_fetch.py --tickers AAPL,MSFT,GOOG --period 1y --output stocks.csv
2024-06-01 10:14:02 [INFO] Requested tickers: AAPL, MSFT, GOOG
2024-06-01 10:14:02 [INFO] Fetching AAPL (period=1y, interval=1d)
2024-06-01 10:14:03 [INFO] Fetching MSFT (period=1y, interval=1d)
2024-06-01 10:14:04 [INFO] Fetching GOOG (period=1y, interval=1d)
2024-06-01 10:14:05 [INFO] Wrote 756 rows to stocks.csv
2. Fetch six months and render a chart:
$ python3 stock_fetch.py --tickers NVDA,AMD --period 6mo --output chips.csv --plot
A matplotlib window pops up showing two closing-price lines.
3. Handle a bad ticker gracefully (script still exits 0 if any succeed):
$ python3 stock_fetch.py --tickers AAPL,ZZZZ --period 1mo
2024-06-01 10:15:00 [INFO] Fetching AAPL (period=1mo, interval=1d)
2024-06-01 10:15:01 [INFO] Fetching ZZZZ (period=1mo, interval=1d)
2024-06-01 10:15:02 [WARNING] No data returned for ZZZZ — skipping (invalid symbol or delisted?)
2024-06-01 10:15:02 [INFO] Wrote 22 rows to stocks.csv
--period / --interval: argparse rejects them with exit code 2 before any network call.--tickers: parse_tickers() raises ValueError; main() logs it and returns 2.yf.download() exceptions are caught per ticker; the script logs the error and continues with the remaining symbols.fetch_all() raises RuntimeError; the script logs and exits with code 1 so cron/CI can detect the failure.matplotlib when --plot is passed: caught at import time with a clear install hint; the CSV is still written.Path.mkdir(parents=True, exist_ok=True) plus pandas' own OSError will surface a traceback — intentional, since permission issues usually need operator attention.--start / --end date range mode using yf.download(start=..., end=...) instead of period — useful for reproducible research windows.save_csv() for a DataFrame.to_sql() call; the long-format schema (Ticker, Date, OHLCV) maps cleanly to a table with a composite primary key.pandas.rolling() after the fetch — a natural next step for analysis pipelines.STOCK_LOG_LEVEL=WARNING and a timestamped output path like data/$(date +%F).csv to build a rolling historical archive automatically.