pounce/backend/app/services/price_tracker.py

269 lines
8.6 KiB
Python

"""TLD Price change tracking and alerting service."""
import logging
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tld_price import TLDPrice
from app.models.user import User
from app.services.email_service import email_service
logger = logging.getLogger(__name__)
@dataclass
class PriceChange:
"""Represents a TLD price change."""
tld: str
registrar: str
old_price: float
new_price: float
change_amount: float
change_percent: float
detected_at: datetime
@property
def is_significant(self) -> bool:
"""Check if price change is significant (>5%)."""
return abs(self.change_percent) >= 5.0
@property
def direction(self) -> str:
"""Get price change direction."""
if self.change_percent > 0:
return "up"
elif self.change_percent < 0:
return "down"
return "stable"
class PriceTracker:
"""
Tracks TLD price changes and sends alerts.
Features:
- Detect significant price changes (>5%)
- Send email alerts to subscribed users
- Generate price change reports
"""
# Threshold for significant price change (percentage)
SIGNIFICANT_CHANGE_THRESHOLD = 5.0
async def detect_price_changes(
self,
db: AsyncSession,
hours: int = 24,
) -> list[PriceChange]:
"""
Detect price changes in the last N hours.
Args:
db: Database session
hours: Look back period in hours
Returns:
List of significant price changes
"""
changes: list[PriceChange] = []
now = datetime.utcnow()
cutoff = now - timedelta(hours=hours)
# PERF: Avoid N+1 queries (distinct(tld, registrar) + per-pair LIMIT 2).
# We fetch the latest 2 rows per (tld, registrar) using a window function.
ranked = (
select(
TLDPrice.tld.label("tld"),
TLDPrice.registrar.label("registrar"),
TLDPrice.registration_price.label("price"),
TLDPrice.recorded_at.label("recorded_at"),
func.row_number()
.over(
partition_by=(TLDPrice.tld, TLDPrice.registrar),
order_by=TLDPrice.recorded_at.desc(),
)
.label("rn"),
)
).subquery()
rows = (
await db.execute(
select(
ranked.c.tld,
ranked.c.registrar,
ranked.c.price,
ranked.c.recorded_at,
ranked.c.rn,
)
.where(ranked.c.rn <= 2)
.order_by(ranked.c.tld, ranked.c.registrar, ranked.c.rn)
)
).all()
from itertools import groupby
for (tld, registrar), grp in groupby(rows, key=lambda r: (r.tld, r.registrar)):
pair = list(grp)
if len(pair) < 2:
continue
newest = pair[0] if pair[0].rn == 1 else pair[1]
previous = pair[1] if pair[0].rn == 1 else pair[0]
# Only consider if the newest price is within the requested window
if newest.recorded_at is None or newest.recorded_at < cutoff:
continue
if not previous.price or previous.price == 0:
continue
change_amount = float(newest.price) - float(previous.price)
change_percent = (change_amount / float(previous.price)) * 100
if abs(change_percent) >= self.SIGNIFICANT_CHANGE_THRESHOLD:
changes.append(
PriceChange(
tld=tld,
registrar=registrar,
old_price=float(previous.price),
new_price=float(newest.price),
change_amount=change_amount,
change_percent=change_percent,
detected_at=newest.recorded_at,
)
)
# Sort by absolute change percentage (most significant first)
changes.sort(key=lambda x: abs(x.change_percent), reverse=True)
logger.info(f"Detected {len(changes)} significant price changes in last {hours}h")
return changes
async def send_price_alerts(
self,
db: AsyncSession,
changes: list[PriceChange],
min_tier: str = "professional",
) -> int:
"""
Send price change alerts to subscribed users.
Args:
db: Database session
changes: List of price changes to alert about
min_tier: Minimum subscription tier to receive alerts
Returns:
Number of alerts sent
"""
if not changes:
return 0
if not email_service.is_enabled:
logger.warning("Email service not configured, skipping price alerts")
return 0
# Get users with appropriate subscription
# For now, send to all active users (can filter by tier later)
result = await db.execute(
select(User).where(User.is_active == True)
)
users = result.scalars().all()
alerts_sent = 0
for change in changes[:10]: # Limit to top 10 changes
for user in users:
try:
success = await email_service.send_price_change_alert(
to_email=user.email,
tld=change.tld,
old_price=change.old_price,
new_price=change.new_price,
change_percent=change.change_percent,
registrar=change.registrar,
)
if success:
alerts_sent += 1
except Exception as e:
logger.error(f"Failed to send alert to {user.email}: {e}")
logger.info(f"Sent {alerts_sent} price change alerts")
return alerts_sent
async def get_trending_changes(
self,
db: AsyncSession,
days: int = 7,
limit: int = 10,
) -> list[dict]:
"""
Get trending price changes for dashboard display.
Args:
db: Database session
days: Look back period
limit: Max results to return
Returns:
List of trending price changes
"""
changes = await self.detect_price_changes(db, hours=days * 24)
return [
{
"tld": c.tld,
"registrar": c.registrar,
"old_price": c.old_price,
"new_price": c.new_price,
"change_percent": round(c.change_percent, 2),
"direction": c.direction,
"detected_at": c.detected_at.isoformat(),
}
for c in changes[:limit]
]
async def generate_price_report(
self,
db: AsyncSession,
days: int = 30,
) -> dict:
"""Generate a comprehensive price change report."""
changes = await self.detect_price_changes(db, hours=days * 24)
increases = [c for c in changes if c.direction == "up"]
decreases = [c for c in changes if c.direction == "down"]
return {
"period_days": days,
"total_changes": len(changes),
"price_increases": len(increases),
"price_decreases": len(decreases),
"avg_increase": round(sum(c.change_percent for c in increases) / len(increases), 2) if increases else 0,
"avg_decrease": round(sum(c.change_percent for c in decreases) / len(decreases), 2) if decreases else 0,
"biggest_increase": {
"tld": increases[0].tld,
"change": round(increases[0].change_percent, 2),
} if increases else None,
"biggest_decrease": {
"tld": decreases[0].tld,
"change": round(decreases[0].change_percent, 2),
} if decreases else None,
"top_changes": [
{
"tld": c.tld,
"change_percent": round(c.change_percent, 2),
"new_price": c.new_price,
}
for c in changes[:10]
],
}
# Singleton instance
price_tracker = PriceTracker()