""" FINRA overlay loader - derive crowding/stress features from the existing finra_short_volume table without duplicating data. """ import logging import statistics from datetime import datetime, timedelta, timezone from typing import Dict from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, func from app.models.finra_short_volume import FinraShortVolume logger = logging.getLogger(__name__) class FinraOverlayLoader: """Calculate crowding stress metrics from FINRA short volume data.""" async def get_crowding_metrics( self, db: AsyncSession, symbol: str, days: int = 30 ) -> Dict: """ Derive crowding stress metrics for a symbol over the last *days* days. Returns a dict with: short_volume_ratio - latest daily short/total ratio short_volume_spike_zscore - how far above the rolling mean crowding_stress_z - negative spike z-score (high → more stress) Returns {} if no FINRA data is available. """ symbol = symbol.upper() cutoff = datetime.now(timezone.utc) - timedelta(days=days) result = await db.execute( select( FinraShortVolume.date, func.sum(FinraShortVolume.short_volume).label("short_volume"), func.sum(FinraShortVolume.total_volume).label("total_volume"), ) .where( and_( FinraShortVolume.symbol == symbol, FinraShortVolume.date >= cutoff, ) ) .group_by(FinraShortVolume.date) .order_by(FinraShortVolume.date) ) rows = result.fetchall() if not rows: return {} # Build daily short ratios ratios = [] for row in rows: _, sv, tv = row if tv and tv > 0: ratios.append(sv / tv) if not ratios: return {} latest_ratio = ratios[-1] # z-score of latest vs rolling window if len(ratios) >= 2: mean_r = statistics.mean(ratios) stdev_r = statistics.stdev(ratios) spike_z = (latest_ratio - mean_r) / stdev_r if stdev_r > 0 else 0.0 else: spike_z = 0.0 # crowding_stress_z: higher short-volume spike → negative stress on price crowding_stress_z = round(-spike_z, 4) return { "short_volume_ratio": round(latest_ratio, 6), "short_volume_spike_zscore": round(spike_z, 4), "crowding_stress_z": crowding_stress_z, }