"""Market context feature computation for aggregation windows. Fetches recent market snapshots from PostgreSQL and computes context features (price change, volume trend, volatility) that enrich trend summaries and modulate signal weighting. Requirements: 6.1, 6.2 """ from __future__ import annotations import math from datetime import datetime, timedelta, timezone from typing import Any import asyncpg from services.shared.schemas import MarketContext, TrendWindow # Map TrendWindow values to lookback durations in days. WINDOW_LOOKBACK_DAYS: dict[str, int] = { TrendWindow.INTRADAY.value: 1, TrendWindow.ONE_DAY.value: 2, TrendWindow.SEVEN_DAY.value: 8, TrendWindow.THIRTY_DAY.value: 35, TrendWindow.NINETY_DAY.value: 95, } async def fetch_market_context( pool: asyncpg.Pool, ticker: str, window: str, reference_time: datetime | None = None, ) -> MarketContext: """Build a MarketContext for *ticker* over the given trend *window*. Queries the ``market_snapshots`` table for recent bars and computes: - price_change_pct: (last_close - first_close) / first_close - avg_volume: mean volume across bars - volume_change_pct: second-half avg volume vs first-half avg volume - volatility: std-dev of close prices - latest_close / latest_bar_at Returns a MarketContext with ``bars_available == 0`` when no data exists. """ if reference_time is None: reference_time = datetime.now(timezone.utc) lookback_days = WINDOW_LOOKBACK_DAYS.get(window, 8) start = reference_time - timedelta(days=lookback_days) rows = await pool.fetch( """ SELECT data, captured_at FROM market_snapshots WHERE ticker = $1 AND captured_at >= $2 AND captured_at <= $3 ORDER BY captured_at ASC """, ticker, start, reference_time, ) if not rows: return MarketContext(ticker=ticker) bars = _extract_bars(rows) if not bars: return MarketContext(ticker=ticker) return _compute_context(ticker, bars) def _extract_bars(rows: list[Any]) -> list[dict[str, Any]]: """Extract OHLCV bar dicts from market_snapshot rows. The ``data`` column is JSONB. Polygon prev-day bars store fields like ``o``, ``h``, ``l``, ``c``, ``v``, ``t``. We normalise to a common dict with ``close``, ``volume``, ``captured_at``. """ bars: list[dict[str, Any]] = [] for row in rows: data = row["data"] if isinstance(data, str): import json data = json.loads(data) # Polygon-style single bar or list of bars items = data if isinstance(data, list) else [data] for item in items: close = item.get("c") or item.get("close") volume = item.get("v") or item.get("volume") if close is not None: bars.append({ "close": float(close), "volume": float(volume) if volume is not None else 0.0, "captured_at": row["captured_at"], }) return bars def _compute_context(ticker: str, bars: list[dict[str, Any]]) -> MarketContext: """Derive market context features from a sorted list of bar dicts.""" closes = [b["close"] for b in bars] volumes = [b["volume"] for b in bars] first_close = closes[0] last_close = closes[-1] price_change_pct = ( ((last_close - first_close) / first_close * 100.0) if first_close != 0 else 0.0 ) avg_volume = sum(volumes) / len(volumes) if volumes else 0.0 # Volume trend: compare second half to first half mid = len(volumes) // 2 if mid > 0: first_half_avg = sum(volumes[:mid]) / mid second_half_avg = sum(volumes[mid:]) / len(volumes[mid:]) volume_change_pct = ( ((second_half_avg - first_half_avg) / first_half_avg * 100.0) if first_half_avg > 0 else 0.0 ) else: volume_change_pct = 0.0 # Volatility: std dev of closes if len(closes) > 1: mean_close = sum(closes) / len(closes) variance = sum((c - mean_close) ** 2 for c in closes) / len(closes) volatility = math.sqrt(variance) else: volatility = 0.0 return MarketContext( ticker=ticker, price_change_pct=round(price_change_pct, 4), avg_volume=round(avg_volume, 2), volume_change_pct=round(volume_change_pct, 4), volatility=round(volatility, 6), latest_close=last_close, latest_bar_at=bars[-1]["captured_at"], bars_available=len(bars), )