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.

86 lines
2.6 KiB
Python

"""
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,
}