"""Utilities for reconstructing daily top-gainer style signals from bars. This module is intentionally generic. A Yahoo-style probe can reuse it by ranking candidates on signal-day return after applying liquidity/quality gates. """ from __future__ import annotations import datetime as dt import statistics from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class DailyGainerCriteria: min_change: float = 0.08 max_change: float | None = 0.25 min_volume_ratio: float = 1.5 min_close_location: float = 0.60 min_avg_dollar_volume: float = 10_000_000.0 max_gap: float | None = None top_n: int = 3 @dataclass(frozen=True) class DailyGainerSignal: symbol: str signal_date: dt.date rank: int signal_return: float volume_ratio_20d: float close_location: float gap_size: float avg_dollar_volume_20d: float signal_close: float @dataclass(frozen=True) class DailyGainerTrade: symbol: str signal_date: dt.date rank: int signal_return: float volume_ratio_20d: float close_location: float gap_size: float avg_dollar_volume_20d: float signal_close: float entry_date: dt.date entry_price: float entry_gap_pct: float hold_1d_return: float | None = None hold_3d_return: float | None = None hold_5d_return: float | None = None def collect_daily_gainer_signals( *, bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]], start_date: dt.date, end_date: dt.date, criteria: DailyGainerCriteria, ) -> dict[dt.date, list[DailyGainerSignal]]: """Return top-N daily gainer signals per date from historical bars.""" candidates_by_date: dict[dt.date, list[DailyGainerSignal]] = {} for raw_symbol, bars in bars_by_symbol.items(): symbol = str(raw_symbol or "").upper() if not symbol or len(bars) < 21: continue sorted_dates = sorted(bars) for idx in range(20, len(sorted_dates)): signal_date = sorted_dates[idx] if signal_date < start_date or signal_date > end_date: continue prev_date = sorted_dates[idx - 1] prev_bar = bars[prev_date] signal_bar = bars[signal_date] prev_close = float(prev_bar.get("close") or 0.0) signal_open = float(signal_bar.get("open") or 0.0) signal_high = float(signal_bar.get("high") or 0.0) signal_low = float(signal_bar.get("low") or 0.0) signal_close = float(signal_bar.get("close") or 0.0) signal_volume = float(signal_bar.get("volume") or 0.0) if prev_close <= 0 or signal_open <= 0 or signal_close <= 0: continue lookback_dates = sorted_dates[idx - 20 : idx] avg_volume = statistics.fmean( float(bars[d].get("volume") or 0.0) for d in lookback_dates ) avg_dollar_volume = statistics.fmean( float(bars[d].get("close") or 0.0) * float(bars[d].get("volume") or 0.0) for d in lookback_dates ) volume_ratio = signal_volume / avg_volume if avg_volume > 0 else 0.0 signal_return = signal_close / prev_close - 1.0 gap_size = signal_open / prev_close - 1.0 intraday_range = signal_high - signal_low close_location = ( (signal_close - signal_low) / intraday_range if intraday_range > 0 else 0.5 ) if signal_return < criteria.min_change: continue if criteria.max_change is not None and signal_return > criteria.max_change: continue if volume_ratio < criteria.min_volume_ratio: continue if close_location < criteria.min_close_location: continue if avg_dollar_volume < criteria.min_avg_dollar_volume: continue if criteria.max_gap is not None and gap_size > criteria.max_gap: continue candidates_by_date.setdefault(signal_date, []).append( DailyGainerSignal( symbol=symbol, signal_date=signal_date, rank=0, signal_return=signal_return, volume_ratio_20d=volume_ratio, close_location=close_location, gap_size=gap_size, avg_dollar_volume_20d=avg_dollar_volume, signal_close=signal_close, ) ) ranked_by_date: dict[dt.date, list[DailyGainerSignal]] = {} for signal_date, signals in candidates_by_date.items(): ranked = sorted( signals, key=lambda item: ( -item.signal_return, -item.volume_ratio_20d, -item.avg_dollar_volume_20d, item.symbol, ), ) trimmed: list[DailyGainerSignal] = [] for rank, signal in enumerate(ranked[: criteria.top_n], start=1): trimmed.append( DailyGainerSignal( symbol=signal.symbol, signal_date=signal.signal_date, rank=rank, signal_return=signal.signal_return, volume_ratio_20d=signal.volume_ratio_20d, close_location=signal.close_location, gap_size=signal.gap_size, avg_dollar_volume_20d=signal.avg_dollar_volume_20d, signal_close=signal.signal_close, ) ) if trimmed: ranked_by_date[signal_date] = trimmed return ranked_by_date def evaluate_daily_gainer_signals( *, signals_by_date: dict[dt.date, list[DailyGainerSignal]], bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]], horizons: tuple[int, ...] = (1, 3, 5), ) -> list[DailyGainerTrade]: """Evaluate next-open continuation returns for reconstructed gainer signals.""" sorted_dates_cache: dict[str, list[dt.date]] = {} date_index_cache: dict[str, dict[dt.date, int]] = {} trades: list[DailyGainerTrade] = [] for signal_date in sorted(signals_by_date): for signal in signals_by_date[signal_date]: symbol = signal.symbol.upper() bars = bars_by_symbol.get(symbol) or {} if len(bars) < 2: continue sorted_dates = sorted_dates_cache.get(symbol) if sorted_dates is None: sorted_dates = sorted(bars) sorted_dates_cache[symbol] = sorted_dates date_index_cache[symbol] = {d: idx for idx, d in enumerate(sorted_dates)} date_index = date_index_cache[symbol] signal_idx = date_index.get(signal.signal_date) if signal_idx is None or signal_idx + 1 >= len(sorted_dates): continue entry_date = sorted_dates[signal_idx + 1] entry_bar = bars.get(entry_date) or {} entry_price = float(entry_bar.get("open") or 0.0) if entry_price <= 0: continue returns: dict[int, float | None] = {h: None for h in horizons} for horizon in horizons: exit_idx = signal_idx + horizon if exit_idx >= len(sorted_dates): continue exit_date = sorted_dates[exit_idx] exit_bar = bars.get(exit_date) or {} exit_close = float(exit_bar.get("close") or 0.0) if exit_close <= 0: continue returns[horizon] = exit_close / entry_price - 1.0 trades.append( DailyGainerTrade( symbol=symbol, signal_date=signal.signal_date, rank=signal.rank, signal_return=signal.signal_return, volume_ratio_20d=signal.volume_ratio_20d, close_location=signal.close_location, gap_size=signal.gap_size, avg_dollar_volume_20d=signal.avg_dollar_volume_20d, signal_close=signal.signal_close, entry_date=entry_date, entry_price=entry_price, entry_gap_pct=entry_price / signal.signal_close - 1.0, hold_1d_return=returns.get(1), hold_3d_return=returns.get(3), hold_5d_return=returns.get(5), ) ) return trades def summarize_metric(values: list[float]) -> dict[str, float | int | None]: """Return compact summary stats for a metric series.""" if not values: return {"count": 0, "mean": None, "median": None, "win_rate": None} return { "count": len(values), "mean": statistics.fmean(values), "median": statistics.median(values), "win_rate": sum(1 for value in values if value > 0) / len(values), }