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