Files
2026-08-31 14:33:53 +09:00

336 lines
14 KiB
Python

"""Price collector: daily OHLCV via one batched yfinance download, plus best-effort after-hours prices.
Semantics
---------
* ``close`` is the Close of the last daily bar dated on/before ``session_date`` (selected by date, never
by position, because the batch frame's index is the union of every ticker's dates and 24h instruments
such as ``KRW=X``/``BTC-USD``/futures carry bars for days on which US equities did not trade).
* For fx / futures / crypto, which trade ~24h, "close" therefore means the last available daily bar on or
before the session date under Yahoo's own trading-day convention, not a 16:00 ET print.
* ``prev_close`` is the Close of the preceding non-NaN daily bar; ``change_pct`` and ``change_abs`` derive
from those two. For the ``rates`` group (``^IRX ^FVX ^TNX ^TYX``) ``change_abs`` = yield points is the
primary metric (the renderer picks it via ``symbols.POINT_CHANGE_GROUPS``); ``change_pct`` is filled too.
* ``avg_volume_10d`` averages the up-to-10 daily bars *before* the session bar (excludes the session day).
* ``after_hours_price`` is filled only for plain equities/ETFs from 1-minute bars stamped at/after 16:00 ET
on the session date. It is best-effort: per-ticker try/except and a total time budget.
* ``as_of`` is the exchange-local calendar date of the daily bar actually used, expressed as midnight
America/New_York, so a stale bar is visible.
Failure policy: the batch download is retried once; if it still fails, a per-ticker ``Ticker.history`` loop
is attempted for the ``indices`` group only; if nothing at all is obtained ``PriceFetchError`` is raised so
``collectors.base.run_safely`` records a data gap. A single missing/bad ticker is skipped and logged.
"""
from __future__ import annotations
import logging
import math
import time
from datetime import date, datetime, timedelta
from datetime import time as dtime
from zoneinfo import ZoneInfo
import pandas as pd
import yfinance as yf
from us2kr import symbols
from us2kr.models import Quote
log = logging.getLogger(__name__)
ET = ZoneInfo("America/New_York")
HISTORY_DAYS = 21 # calendar days of daily bars to pull (>= 10 sessions + holidays margin)
AVG_VOLUME_WINDOW = 10 # sessions used for avg_volume_10d
MIN_AVG_VOLUME_BARS = 3 # fewer prior bars than this -> avg_volume_10d = None
AFTER_HOURS_BUDGET_SECONDS = 45.0 # total wall-clock budget for the after-hours loop
REGULAR_CLOSE_ET = dtime(16, 0) # after-hours bars are those stamped at/after this local time
DOWNLOAD_TIMEOUT_SECONDS = 20
_REQUIRED_COLS = ("Close",)
_FALLBACK_GROUP = "indices"
class PriceFetchError(RuntimeError):
"""Raised when no price data at all could be obtained (batch + fallback both failed)."""
# --------------------------------------------------------------------------------------------------
# public API
# --------------------------------------------------------------------------------------------------
def fetch_quotes(session_date: date, tickers: list[str] | None = None) -> list[Quote]:
"""Return one Quote per available ticker for the US session ``session_date``.
``tickers`` defaults to ``symbols.all_tickers()``. Output order follows the input order. Tickers that
yield no usable bars are skipped (logged), never raised. Raises ``PriceFetchError`` only when the
whole batch and the fallback fail.
"""
wanted = list(tickers) if tickers is not None else symbols.all_tickers()
if not wanted:
return []
frames = _download_history(session_date, wanted)
quotes: list[Quote] = []
for ticker in wanted:
frame = frames.get(ticker)
if frame is None or frame.empty:
log.warning("prices: %s missing from download - skipped", ticker)
continue
try:
quote = _quote_from_history(ticker, frame, session_date)
except Exception: # one bad ticker must not sink the batch
log.exception("prices: %s could not be parsed - skipped", ticker)
continue
if quote is None:
log.warning("prices: %s has no bar on/before %s - skipped", ticker, session_date)
continue
quotes.append(quote)
log.info("prices: %d/%d tickers quoted for %s", len(quotes), len(wanted), session_date)
_fill_after_hours(quotes, session_date)
return quotes
# --------------------------------------------------------------------------------------------------
# download layer
# --------------------------------------------------------------------------------------------------
def _download_history(session_date: date, tickers: list[str]) -> dict[str, pd.DataFrame]:
"""Batched daily download (retry once), then per-ticker fallback for indices. Never returns empty."""
start = session_date - timedelta(days=HISTORY_DAYS)
end = session_date + timedelta(days=1) # yfinance ``end`` is exclusive
errors: list[str] = []
for attempt in (1, 2):
try:
raw = yf.download(
tickers,
start=start.isoformat(),
end=end.isoformat(),
interval="1d",
group_by="ticker",
auto_adjust=False,
threads=True,
progress=False,
timeout=DOWNLOAD_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - network / parsing errors from yfinance
errors.append(f"batch attempt {attempt}: {type(exc).__name__}: {exc}")
log.warning("prices: batch download attempt %d failed: %s", attempt, exc)
continue
frames = _split_frame(raw, tickers)
if frames:
return frames
errors.append(f"batch attempt {attempt}: empty frame")
log.warning("prices: batch download attempt %d returned no data", attempt)
fallback_tickers = [t for t in tickers if symbols.group_of(t) == _FALLBACK_GROUP]
log.warning("prices: batch failed twice; falling back to per-ticker history for %s", fallback_tickers)
frames: dict[str, pd.DataFrame] = {}
for ticker in fallback_tickers:
try:
hist = yf.Ticker(ticker).history(
start=start.isoformat(),
end=end.isoformat(),
interval="1d",
auto_adjust=False,
actions=False,
timeout=DOWNLOAD_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001
errors.append(f"{ticker}: {type(exc).__name__}: {exc}")
log.warning("prices: fallback history for %s failed: %s", ticker, exc)
continue
cleaned = _clean_single(hist)
if cleaned is not None:
frames[ticker] = cleaned
if frames:
return frames
raise PriceFetchError("no price data obtained; " + "; ".join(errors)[:500])
def _split_frame(raw: pd.DataFrame | None, tickers: list[str]) -> dict[str, pd.DataFrame]:
"""Turn a yf.download result into {ticker: single-level OHLCV frame}, dropping all-NaN tickers."""
if raw is None or not isinstance(raw, pd.DataFrame) or raw.empty:
return {}
frames: dict[str, pd.DataFrame] = {}
if isinstance(raw.columns, pd.MultiIndex):
# Expected layout (group_by="ticker"): level0 = ticker, level1 = price field.
# Defensive: if level0 holds price fields instead (group_by="column" layout), swap.
level0 = set(raw.columns.get_level_values(0))
level1 = set(raw.columns.get_level_values(1))
if "Close" in level0 and "Close" not in level1:
raw = raw.swaplevel(0, 1, axis=1)
level0 = set(raw.columns.get_level_values(0))
for ticker in tickers:
key = ticker if ticker in level0 else ticker.upper() if ticker.upper() in level0 else None
if key is None:
continue
cleaned = _clean_single(raw[key])
if cleaned is not None:
frames[ticker] = cleaned
return frames
# Single-level columns: only meaningful when exactly one ticker was requested.
if len(tickers) == 1:
cleaned = _clean_single(raw)
if cleaned is not None:
frames[tickers[0]] = cleaned
else:
log.warning("prices: single-level columns for %d tickers - cannot attribute, ignoring", len(tickers))
return frames
def _clean_single(frame: pd.DataFrame | None) -> pd.DataFrame | None:
"""Normalise a per-ticker OHLCV frame: naive date index, rows with a Close only; None if unusable."""
if frame is None or not isinstance(frame, pd.DataFrame) or frame.empty:
return None
if isinstance(frame.columns, pd.MultiIndex):
frame = frame.copy()
frame.columns = frame.columns.get_level_values(-1)
if any(col not in frame.columns for col in _REQUIRED_COLS):
return None
idx = pd.to_datetime(frame.index)
if idx.tz is not None:
# yfinance stamps daily bars at local midnight in the exchange tz (Europe/London for KRW=X/JPY=X,
# UTC for BTC-USD, America/New_York or America/Chicago for US indices). Converting to ET would move
# midnight-London/UTC bars to the previous evening ET and relabel them one day early, so just drop
# the tz and keep the exchange-local date, consistent with the tz-naive batch download.
idx = idx.tz_localize(None)
frame = frame.copy()
frame.index = idx.normalize()
frame = frame[frame["Close"].notna()]
if frame.empty:
return None
return frame.sort_index()
# --------------------------------------------------------------------------------------------------
# quote computation
# --------------------------------------------------------------------------------------------------
def _quote_from_history(ticker: str, frame: pd.DataFrame, session_date: date) -> Quote | None:
"""Build a Quote from a cleaned daily frame using the last bar on/before ``session_date``."""
hist = frame[frame.index <= pd.Timestamp(session_date)]
if hist.empty:
return None
row = hist.iloc[-1]
bar_date: date = hist.index[-1].date()
if bar_date != session_date:
log.info("prices: %s has no %s bar; using last available bar %s", ticker, session_date, bar_date)
close = _num(row.get("Close"))
if close is None:
return None
prev_close = _num(hist.iloc[-2].get("Close")) if len(hist) >= 2 else None
change_abs = change_pct = None
if prev_close is not None:
change_abs = close - prev_close
if prev_close != 0:
change_pct = (close / prev_close - 1.0) * 100.0
volume = _num(row.get("Volume"))
if volume is not None and volume <= 0:
volume = None # fx / rates / indices without volume report 0
prior_vol = hist["Volume"].iloc[-(AVG_VOLUME_WINDOW + 1) : -1] if "Volume" in hist.columns else None
avg_volume: float | None = None
if prior_vol is not None:
prior_vol = pd.to_numeric(prior_vol, errors="coerce").dropna()
prior_vol = prior_vol[prior_vol > 0]
if len(prior_vol) >= MIN_AVG_VOLUME_BARS:
avg_volume = float(prior_vol.mean())
return Quote(
symbol=ticker,
name=symbols.display_name(ticker),
group=symbols.group_of(ticker) or "other",
close=close,
prev_close=prev_close,
change_pct=change_pct,
change_abs=change_abs,
day_high=_num(row.get("High")),
day_low=_num(row.get("Low")),
volume=volume,
avg_volume_10d=avg_volume,
as_of=datetime.combine(bar_date, dtime.min, tzinfo=ET),
source="yfinance",
)
def _num(value: object) -> float | None:
"""Coerce a pandas scalar to float, mapping NaN/None/non-numeric to None."""
if value is None:
return None
try:
out = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
return None if math.isnan(out) or math.isinf(out) else out
# --------------------------------------------------------------------------------------------------
# after-hours (best effort)
# --------------------------------------------------------------------------------------------------
def _is_equity_like(ticker: str) -> bool:
"""Plain equities/ETFs only (no indices ``^``, futures ``=F``, fx ``=X``, crypto ``-USD``, ``DX-Y.NYB``)."""
return ticker.isascii() and ticker.isalpha()
def _fill_after_hours(quotes: list[Quote], session_date: date) -> None:
"""Populate after_hours_price / after_hours_change_pct in place; never raises."""
deadline = time.monotonic() + AFTER_HOURS_BUDGET_SECONDS
attempted = filled = 0
for quote in quotes:
if quote.close is None or not _is_equity_like(quote.symbol):
continue
if time.monotonic() >= deadline:
log.warning("prices: after-hours budget exhausted after %d tickers", attempted)
break
attempted += 1
try:
price = _after_hours_price(quote.symbol, session_date)
except Exception as exc: # noqa: BLE001 - best effort only
log.debug("prices: after-hours lookup failed for %s: %s", quote.symbol, exc)
continue
if price is None:
continue
quote.after_hours_price = price
quote.after_hours_change_pct = (price / quote.close - 1.0) * 100.0 if quote.close else None
filled += 1
if attempted:
log.info("prices: after-hours filled for %d/%d equity tickers", filled, attempted)
def _after_hours_price(ticker: str, session_date: date) -> float | None:
"""Last 1-minute Close stamped at/after 16:00 ET on ``session_date``; None when unavailable."""
hist = yf.Ticker(ticker).history(
start=session_date.isoformat(),
end=(session_date + timedelta(days=1)).isoformat(),
interval="1m",
prepost=True,
auto_adjust=False,
actions=False,
timeout=DOWNLOAD_TIMEOUT_SECONDS,
)
if hist is None or hist.empty or "Close" not in hist.columns:
return None
idx = pd.to_datetime(hist.index)
idx = idx.tz_convert(ET) if idx.tz is not None else idx.tz_localize(ET)
same_day = idx.date == session_date # type: ignore[attr-defined]
post = hist[same_day & (idx.time >= REGULAR_CLOSE_ET)] # type: ignore[attr-defined]
post = post[post["Close"].notna()]
if post.empty:
return None
return _num(post["Close"].iloc[-1])