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.

217 lines
7.9 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.

"""
Dynamic StockTwits subscription universe.
Single V49 universe ("today only") would create a 16-hour data hole during
the premarket window (prev 16:00 ET → today 09:30 ET). Instead we union:
(last N days of UniverseSnapshot active tickers)
(today's premarket gap movers above threshold)
Capped at STOCKTWITS_UNIVERSE_MAX_SIZE (default 300) to fit comfortably under
the 200-req/hr StockTwits ceiling at one poll per 5 minutes.
Computed once per trading day at 09:00 ET and cached in Redis under
`news_v2:stocktwits:universe`. The poller reads that key each cycle.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
import orjson
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.models.universe_snapshot import UniverseSnapshot
from app.utils.cache import get_redis
logger = logging.getLogger(__name__)
REDIS_KEY = "news_v2:stocktwits:universe"
REDIS_TTL_SEC = 24 * 60 * 60 # 1 day
async def compute_stocktwits_universe(
db: AsyncSession | None = None,
lookback_days: int | None = None,
max_size: int | None = None,
gap_threshold: float | None = None,
) -> list[str]:
"""Compute and persist the StockTwits subscription universe to Redis."""
lookback_days = lookback_days or settings.STOCKTWITS_UNIVERSE_LOOKBACK_DAYS
max_size = max_size or settings.STOCKTWITS_UNIVERSE_MAX_SIZE
gap_threshold = gap_threshold if gap_threshold is not None else settings.STOCKTWITS_PREMARKET_GAP_THRESHOLD
own_session = db is None
if own_session:
db = AsyncSessionLocal()
await db.__aenter__() # type: ignore[attr-defined]
try:
recent_universe = await _recent_universe_tickers(db, lookback_days) # type: ignore[arg-type]
movers = await _premarket_gap_movers(db, gap_threshold) # type: ignore[arg-type]
finally:
if own_session and db is not None:
await db.__aexit__(None, None, None) # type: ignore[attr-defined]
union: list[str] = []
seen: set[str] = set()
# Movers first so they survive if we hit max_size truncation
for t in movers + recent_universe:
if t and t not in seen:
seen.add(t)
union.append(t)
if len(union) >= max_size:
break
await _persist_to_redis(union)
logger.info(
f"StockTwits universe: {len(union)} tickers "
f"({len(movers)} movers, {len(recent_universe)} recent universe, capped at {max_size})"
)
return union
async def get_cached_universe() -> list[str]:
"""Read the current Redis-cached universe; returns [] if absent."""
redis = await get_redis()
if redis is None:
return []
try:
raw = await redis.get(REDIS_KEY)
if raw is None:
return []
data = orjson.loads(raw)
if isinstance(data, list):
return [str(t).upper() for t in data]
except Exception as e:
logger.warning(f"StockTwits universe read failed: {e}")
return []
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
async def _recent_universe_tickers(db: AsyncSession, lookback_days: int) -> list[str]:
"""Tickers from UniverseSnapshot, ranked by market cap.
Prefers the recent ``lookback_days`` window. If that's empty (the monthly
snapshot job hasn't refreshed in a while), falls back to the most recent
snapshot in the table — top market-cap tickers stay roughly stable across
months, so a stale snapshot is still useful for keeping the StockTwits
poll alive instead of returning [].
"""
from sqlalchemy import desc, func
cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
# No DISTINCT here — caller dedupes via a set. Adding DISTINCT alongside
# ORDER BY market_cap fights asyncpg's "ORDER BY must appear in SELECT
# list" rule for SELECT DISTINCT.
stmt = (
select(UniverseSnapshot.ticker)
.where(UniverseSnapshot.snapshot_date >= cutoff)
.order_by(desc(UniverseSnapshot.market_cap))
)
rows = (await db.execute(stmt)).scalars().all()
if rows:
return [t.upper() for t in rows if t]
# Fallback — pick the latest snapshot date and take its top tickers.
latest_dt = (
await db.execute(select(func.max(UniverseSnapshot.snapshot_date)))
).scalar()
if not latest_dt:
return []
fallback_stmt = (
select(UniverseSnapshot.ticker)
.where(UniverseSnapshot.snapshot_date == latest_dt)
.order_by(desc(UniverseSnapshot.market_cap))
)
rows = (await db.execute(fallback_stmt)).scalars().all()
logger.warning(
f"StockTwits universe: no snapshots in last {lookback_days}d; "
f"falling back to {latest_dt.date()} snapshot ({len(rows)} tickers)"
)
return [t.upper() for t in rows if t]
async def _premarket_gap_movers(db: AsyncSession, gap_threshold: float) -> list[str]:
"""
Today's premarket gap movers above |threshold|.
Sources:
AlpacaPriceData: yesterday's daily close (1d) + today's intraday (1m/5m)
first available premarket bar.
Returns at most a few hundred tickers; ordered by absolute gap descending
so movers survive max_size truncation.
"""
# Lazy import to keep startup fast & break import cycles
from app.models.alpaca_price import AlpacaPriceData
today_utc = datetime.now(timezone.utc).date()
yday_utc = today_utc - timedelta(days=1)
# Daily close for the prior trading day
daily_stmt = select(
AlpacaPriceData.ticker, AlpacaPriceData.close, AlpacaPriceData.date
).where(
AlpacaPriceData.interval == "1d",
AlpacaPriceData.date >= datetime(yday_utc.year, yday_utc.month, yday_utc.day, tzinfo=timezone.utc) - timedelta(days=4),
AlpacaPriceData.date < datetime(today_utc.year, today_utc.month, today_utc.day, tzinfo=timezone.utc),
)
daily_rows = (await db.execute(daily_stmt)).all()
# Sort ascending by date so the loop's last write per ticker is the latest
last_close: dict[str, float] = {}
for tkr, close, dt in sorted(daily_rows, key=lambda r: r[2]):
last_close[tkr] = float(close)
if not last_close:
return []
# Premarket bars: between today 04:00 ET (≈ 08:00 UTC EDT, 09:00 EST) and 09:30 ET
today_start = datetime(today_utc.year, today_utc.month, today_utc.day, 8, 0, tzinfo=timezone.utc)
today_open = datetime(today_utc.year, today_utc.month, today_utc.day, 13, 30, tzinfo=timezone.utc)
pm_stmt = select(
AlpacaPriceData.ticker, AlpacaPriceData.close, AlpacaPriceData.date
).where(
AlpacaPriceData.interval.in_(("1m", "5m", "15m")),
AlpacaPriceData.date >= today_start,
AlpacaPriceData.date < today_open,
AlpacaPriceData.ticker.in_(list(last_close.keys())),
)
pm_rows = (await db.execute(pm_stmt)).all()
# Keep latest premarket close per ticker
pm_last: dict[str, float] = {}
for tkr, close, dt in sorted(pm_rows, key=lambda r: r[2]):
pm_last[tkr] = float(close)
gaps: list[tuple[str, float]] = []
for tkr, pm_close in pm_last.items():
prev_close = last_close.get(tkr)
if not prev_close or prev_close == 0:
continue
gap = (pm_close - prev_close) / prev_close
if abs(gap) >= gap_threshold:
gaps.append((tkr, abs(gap)))
gaps.sort(key=lambda x: x[1], reverse=True)
return [t for t, _ in gaps]
async def _persist_to_redis(tickers: list[str]) -> None:
redis = await get_redis()
if redis is None:
logger.warning("Redis unavailable — StockTwits universe not persisted")
return
try:
await redis.set(REDIS_KEY, orjson.dumps(tickers), ex=REDIS_TTL_SEC)
except Exception as e:
logger.warning(f"Redis set failed: {e}")