You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

143 lines
5.4 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""Synthetic Yahoo Top Gainer list reconstruction from intraday bar data.
Since Yahoo Finance does not provide historical day_gainers records, we
reconstruct a *synthetic* ranking at each bar tick during the collection window
by computing each ticker's percent change from previous close using its
intraday bars. This mirrors the Yahoo day_gainers screener filter:
- percent_change > 3% (we use the caller-supplied min filter)
- US equities (guaranteed by universe)
- market cap / dollar volume surrogate filter applied by caller
"""
from __future__ import annotations
import datetime as dt
from typing import Any
from zoneinfo import ZoneInfo
_ET_ZONE = ZoneInfo("America/New_York")
def _parse_ts(ts_str: str) -> dt.datetime:
"""Parse ISO timestamp to naive UTC datetime for comparison."""
import dateutil.parser as dparse
parsed = dparse.parse(ts_str)
if parsed.tzinfo is not None:
parsed = parsed.astimezone(dt.timezone.utc).replace(tzinfo=None)
return parsed
def _bar_ts_naive_utc(bar: dict) -> dt.datetime:
return _parse_ts(bar["timestamp"])
def _et_to_utc_naive(date: dt.date, hour: int, minute: int) -> dt.datetime:
"""Convert an ET time on a given date to a naive UTC datetime (DST-aware)."""
et_aware = dt.datetime(date.year, date.month, date.day, hour, minute,
tzinfo=_ET_ZONE)
return et_aware.astimezone(dt.timezone.utc).replace(tzinfo=None)
# ── Snapshot reconstruction ────────────────────────────────────────────────────
# Default V1 collection ticks (09:3009:55 ET, 5-min bars)
_COLLECTION_TICKS_ET_V1 = [
dt.time(9, 30), dt.time(9, 35), dt.time(9, 40),
dt.time(9, 45), dt.time(9, 50), dt.time(9, 55),
]
# Default V2 collection ticks (09:3510:15 ET, 1-min bars)
_COLLECTION_TICKS_ET_V2 = [
dt.time(9, 35), dt.time(9, 45), dt.time(10, 0), dt.time(10, 15),
]
def reconstruct_gainer_snapshots(
bars_by_symbol: dict[str, list[dict]],
prev_closes: dict[str, float],
date_str: str,
min_pct_change: float = 0.03,
top_n: int = 100,
collection_ticks_et: list[dt.time] | None = None,
) -> list[dict[str, Any]]:
"""Build synthetic snapshot rows for each tick during the collection window.
Args:
bars_by_symbol: {symbol: [bar_dict, ...]} from IntradayCache.
prev_closes: {symbol: prev_close_price} from DailyBarCache.
date_str: 'YYYY-MM-DD'
min_pct_change: minimum gain to appear (Yahoo ~3%).
top_n: maximum symbols per tick.
collection_ticks_et: list of ET times to reconstruct rankings at.
Defaults to V1 ticks (09:3009:55 every 5m).
Pass _COLLECTION_TICKS_ET_V2 for V2 dataset builds.
Returns:
List of snapshot dicts: {captured_at, symbol, rank, price, pct_change, volume, market_cap}
captured_at is an ISO string corresponding to the tick timestamp.
market_cap is None (not available in bar data; use enrichment surrogate).
"""
ticks = collection_ticks_et if collection_ticks_et is not None else _COLLECTION_TICKS_ET_V1
date = dt.date.fromisoformat(date_str)
rows: list[dict[str, Any]] = []
for tick_time in ticks:
tick_cutoff_utc = _et_to_utc_naive(date, tick_time.hour, tick_time.minute)
tick_dt_et = dt.datetime(date.year, date.month, date.day,
tick_time.hour, tick_time.minute, 0)
tick_iso = tick_dt_et.strftime("%Y-%m-%dT%H:%M:00")
candidates: list[dict[str, Any]] = []
for sym, bars in bars_by_symbol.items():
prev_close = prev_closes.get(sym)
if not prev_close or prev_close <= 0:
continue
# Find the latest bar whose timestamp <= tick cutoff
latest_bar: dict | None = None
for b in bars:
b_ts = _bar_ts_naive_utc(b)
if b_ts <= tick_cutoff_utc:
latest_bar = b
else:
break # bars are sorted ascending
if latest_bar is None:
continue
price = float(latest_bar["close"])
if price <= 0:
continue
pct_change = (price - prev_close) / prev_close
if pct_change < min_pct_change:
continue
# Volume so far
cum_vol = sum(
float(b.get("volume", 0) or 0)
for b in bars
if _bar_ts_naive_utc(b) <= tick_cutoff_utc
)
candidates.append({
"symbol": sym,
"price": price,
"pct_change": pct_change,
"volume": cum_vol,
"market_cap": None, # not available; caller applies dollar-vol filter
})
# Sort descending by pct_change, take top_n
candidates.sort(key=lambda c: c["pct_change"], reverse=True)
for rank, cand in enumerate(candidates[:top_n], start=1):
rows.append({
"captured_at": tick_iso,
"symbol": cand["symbol"],
"rank": rank,
"price": cand["price"],
"pct_change": cand["pct_change"],
"volume": cand["volume"],
"market_cap": cand["market_cap"],
})
return rows