Initial commit

This commit is contained in:
2026-02-01 09:31:38 +01:00
commit e02db93960
4396 changed files with 1511612 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
"""
API Key Service.
This module handles API key generation, validation, and management.
"""
import secrets
import hashlib
from typing import Optional
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.api_key import ApiKey
from app.models.user import User
class ApiKeyService:
"""Service for managing API keys."""
def __init__(self, db: Session):
self.db = db
def generate_api_key(self, user_id: int, rate_limit: int = 100) -> ApiKey:
"""
Generate a new API key for a user.
Args:
user_id: ID of the user
rate_limit: Rate limit per minute (default: 100)
Returns:
Created ApiKey object
Raises:
ValueError: If user doesn't exist
"""
# Verify user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"User with id {user_id} not found")
# Generate API key (32 bytes = 256 bits)
api_key = secrets.token_urlsafe(32)
# Hash the API key for storage (SHA-256)
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
# Store prefix for identification (first 8 characters)
key_prefix = api_key[:8]
# Create API key record
api_key_record = ApiKey(
user_id=user_id,
key_hash=key_hash,
key_prefix=key_prefix,
rate_limit=rate_limit,
is_active=True,
created_at=datetime.utcnow()
)
self.db.add(api_key_record)
self.db.commit()
self.db.refresh(api_key_record)
# Return the plain API key (only time it's shown)
# We'll add it to the dict temporarily for the response
api_key_dict = api_key_record.to_dict()
api_key_dict['api_key'] = api_key
return api_key_dict
def validate_api_key(self, api_key: str) -> Optional[ApiKey]:
"""
Validate an API key.
Args:
api_key: The API key to validate
Returns:
ApiKey object if valid, None otherwise
"""
# Hash the provided API key
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
# Query for matching hash
api_key_record = self.db.query(ApiKey).filter(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True
).first()
if api_key_record:
# Update last used timestamp
api_key_record.last_used_at = datetime.utcnow()
self.db.commit()
self.db.refresh(api_key_record)
return api_key_record
return None
def get_user_api_keys(self, user_id: int) -> list[dict]:
"""
Get all API keys for a user.
Args:
user_id: ID of the user
Returns:
List of API key dictionaries (without actual keys)
"""
api_keys = self.db.query(ApiKey).filter(
ApiKey.user_id == user_id
).all()
return [api_key.to_dict() for api_key in api_keys]
def revoke_api_key(self, api_key_id: int, user_id: int) -> bool:
"""
Revoke (deactivate) an API key.
Args:
api_key_id: ID of the API key to revoke
user_id: ID of the user (for authorization)
Returns:
True if revoked, False otherwise
"""
api_key = self.db.query(ApiKey).filter(
ApiKey.id == api_key_id,
ApiKey.user_id == user_id
).first()
if api_key:
api_key.is_active = False
self.db.commit()
return True
return False
def regenerate_api_key(self, api_key_id: int, user_id: int) -> Optional[dict]:
"""
Regenerate an API key (create new, deactivate old).
Args:
api_key_id: ID of the API key to regenerate
user_id: ID of the user (for authorization)
Returns:
New API key dict with plain key, or None if not found
"""
old_api_key = self.db.query(ApiKey).filter(
ApiKey.id == api_key_id,
ApiKey.user_id == user_id
).first()
if not old_api_key:
return None
# Get rate limit from old key
rate_limit = old_api_key.rate_limit
# Deactivate old key
old_api_key.is_active = False
self.db.commit()
# Generate new key
return self.generate_api_key(user_id, rate_limit)

View File

@@ -0,0 +1,200 @@
"""
Backtesting Service.
This module provides the service layer for backtesting operations,
integrating with the database to run backtesting on historical matches.
"""
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional
from sqlalchemy.orm import Session
from app.models.match import Match
from app.models.energy_score import EnergyScore
from app.ml.backtesting import (
run_backtesting_batch,
export_to_json,
export_to_csv,
export_to_html,
filter_matches_by_league,
filter_matches_by_period
)
logger = logging.getLogger(__name__)
class BacktestingService:
"""Service for running backtesting on historical match data."""
def __init__(self, db: Session):
"""
Initialize backtesting service.
Args:
db: SQLAlchemy database session
"""
self.db = db
def get_historical_matches(
self,
leagues: Optional[List[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Retrieve historical matches with energy scores and actual results.
Args:
leagues: Optional list of leagues to filter by
start_date: Optional start date for filtering
end_date: Optional end date for filtering
Returns:
List of match dictionaries with energy scores and actual winners
Raises:
ValueError: If no historical matches found
"""
logger.info("Fetching historical matches from database")
# Query matches that have actual_winner set (completed matches)
query = self.db.query(Match).filter(
Match.actual_winner.isnot(None)
)
# Apply filters
if leagues:
query = query.filter(Match.league.in_(leagues))
if start_date:
query = query.filter(Match.date >= start_date)
if end_date:
query = query.filter(Match.date <= end_date)
matches = query.all()
if not matches:
raise ValueError(
"No historical matches found. Please populate database with "
"historical match data and actual winners before running backtesting."
)
logger.info(f"Found {len(matches)} historical matches")
# Convert to list of dictionaries with energy scores
match_data = []
for match in matches:
# Get energy scores for this match
home_energy_score = self.db.query(EnergyScore).filter(
EnergyScore.match_id == match.id
).first()
if not home_energy_score:
logger.warning(f"No energy score found for match {match.id}, skipping")
continue
match_dict = {
'match_id': match.id,
'home_team': match.home_team,
'away_team': match.away_team,
'date': match.date,
'league': match.league,
'home_energy': home_energy_score.home_energy,
'away_energy': home_energy_score.away_energy,
'actual_winner': match.actual_winner
}
match_data.append(match_dict)
logger.info(f"Processed {len(match_data)} matches with energy scores")
return match_data
def run_backtesting(
self,
leagues: Optional[List[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Run backtesting on historical matches with optional filters.
Args:
leagues: Optional list of leagues to filter by
start_date: Optional start date for filtering
end_date: Optional end date for filtering
Returns:
Dictionary containing backtesting results
Raises:
ValueError: If no matches found or matches lack required data
"""
logger.info("Starting backtesting process")
# Get historical matches
matches = self.get_historical_matches(
leagues=leagues,
start_date=start_date,
end_date=end_date
)
# Apply filters
if leagues:
matches = filter_matches_by_league(matches, leagues)
if start_date or end_date:
matches = filter_matches_by_period(matches, start_date, end_date)
if not matches:
raise ValueError("No matches match the specified filters")
logger.info(f"Running backtesting on {len(matches)} matches")
# Run backtesting
result = run_backtesting_batch(matches)
# Log results
logger.info(
f"Backtesting complete: {result['total_matches']} matches, "
f"{result['correct_predictions']} correct, "
f"{result['accuracy']:.2f}% accuracy, "
f"status: {result['status']}"
)
return result
def export_results(
self,
backtesting_result: Dict[str, Any],
format: str = 'json'
) -> str:
"""
Export backtesting results in specified format.
Args:
backtesting_result: Result from run_backtesting
format: Export format ('json', 'csv', or 'html')
Returns:
Formatted string in specified format
Raises:
ValueError: If format is not supported
"""
logger.info(f"Exporting backtesting results as {format}")
if format == 'json':
return export_to_json(backtesting_result)
elif format == 'csv':
return export_to_csv(backtesting_result)
elif format == 'html':
return export_to_html(backtesting_result)
else:
raise ValueError(
f"Unsupported export format: {format}. "
"Supported formats are: json, csv, html"
)

View File

@@ -0,0 +1,190 @@
"""
Service de gestion des badges
"""
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from datetime import datetime
from app.models.badge import Badge, UserBadge
from app.models.user import User
from app.models.user_prediction import UserPrediction
from app.lib.badges import (
BADGES,
isBadgeUnlocked,
getBadgeById,
)
class BadgeService:
"""Service pour la gestion des badges"""
def __init__(self, db: Session):
self.db = db
def get_user_criteria(self, user_id: int) -> Dict[str, int]:
"""
Récupère les critères actuels de l'utilisateur
"""
# Nombre de prédictions consultées
predictions_count = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).count()
# Nombre de prédictions correctes (pour l'instant on utilise une valeur par défaut)
# TODO: Implémenter la logique de calcul des prédictions correctes
correct_predictions = 0
# Nombre de jours consécutifs (streak)
# TODO: Implémenter la logique de calcul de streak
streak_days = 0
# Nombre de partages
# TODO: Implémenter la logique de suivi des partages
share_count = 0
# Nombre de parrainages
# TODO: Implémenter la logique de suivi des parrainages
referral_count = 0
return {
"predictions_count": predictions_count,
"correct_predictions": correct_predictions,
"streak_days": streak_days,
"share_count": share_count,
"referral_count": referral_count,
}
def check_and_unlock_badges(self, user_id: int) -> Dict[str, Any]:
"""
Vérifie et débloque les nouveaux badges pour un utilisateur
"""
# Récupérer les critères actuels de l'utilisateur
user_criteria = self.get_user_criteria(user_id)
# Récupérer les badges déjà débloqués par l'utilisateur
unlocked_badges = self.db.query(UserBadge).filter(
UserBadge.user_id == user_id
).all()
unlocked_badge_ids = set()
for ub in unlocked_badges:
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
if badge:
unlocked_badge_ids.add(badge.badge_id)
# Vérifier tous les badges potentiels
newly_unlocked_badges = []
for badge_def in BADGES:
if badge_def["id"] not in unlocked_badge_ids:
# Vérifier si le badge peut être débloqué
criteria_type = badge_def["criteria"]["type"]
criteria_value = badge_def["criteria"]["value"]
if user_criteria[criteria_type] >= criteria_value:
# Débloquer le badge
new_badge = self._unlock_badge(user_id, badge_def)
if new_badge:
newly_unlocked_badges.append(new_badge)
total_badges = len(unlocked_badges) + len(newly_unlocked_badges)
# Générer un message de notification
message = ""
if len(newly_unlocked_badges) > 0:
if len(newly_unlocked_badges) == 1:
message = f'🎉 Félicitations ! Vous avez débloqué le badge "{newly_unlocked_badges[0]["name"]}" !'
else:
badge_names = ', '.join(b["name"] for b in newly_unlocked_badges)
message = f'🎉 Félicitations ! Vous avez débloqué {len(newly_unlocked_badges)} nouveaux badges : {badge_names} !'
return {
"new_badges": newly_unlocked_badges,
"total_badges": total_badges,
"message": message,
}
def _unlock_badge(self, user_id: int, badge_def: Dict[str, Any]) -> Dict[str, Any] | None:
"""
Débloque un badge pour un utilisateur
"""
try:
# Récupérer ou créer le badge dans la base de données
db_badge = self.db.query(Badge).filter(
Badge.badge_id == badge_def["id"]
).first()
if not db_badge:
# Créer le badge dans la base de données
db_badge = Badge(
badge_id=badge_def["id"],
name=badge_def["name"],
description=badge_def["description"],
icon=badge_def["icon"],
category=badge_def["category"],
criteria_type=badge_def["criteria"]["type"],
criteria_value=badge_def["criteria"]["value"],
criteria_description=badge_def["criteria"]["description"],
rarity=badge_def["rarity"],
points=badge_def["points"],
created_at=datetime.utcnow(),
)
self.db.add(db_badge)
self.db.flush()
# Créer le badge utilisateur
user_badge = UserBadge(
user_id=user_id,
badge_id=db_badge.id,
unlocked_at=datetime.utcnow(),
)
self.db.add(user_badge)
self.db.commit()
return {
"id": db_badge.id,
"badgeId": db_badge.badge_id,
"name": db_badge.name,
"description": db_badge.description,
"icon": db_badge.icon,
"category": db_badge.category,
"rarity": db_badge.rarity,
"points": db_badge.points,
}
except Exception as e:
self.db.rollback()
print(f"Erreur lors du déblocage du badge {badge_def['id']}: {e}")
return None
def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
Récupère tous les badges débloqués par un utilisateur
"""
user_badges = self.db.query(UserBadge).filter(
UserBadge.user_id == user_id
).all()
result = []
for ub in user_badges:
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
if badge:
result.append({
"id": ub.id,
"userId": ub.user_id,
"badgeId": ub.badge_id,
"unlockedAt": ub.unlocked_at.isoformat(),
"badge": {
"id": badge.id,
"badgeId": badge.badge_id,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"rarity": badge.rarity,
"points": badge.points,
"createdAt": badge.created_at.isoformat(),
}
})
return result

View File

@@ -0,0 +1,278 @@
"""
Energy Score Service.
This module provides business logic for energy score calculation and storage.
"""
from datetime import datetime
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.ml.energy_calculator import (
calculate_energy_score,
adjust_weights_for_degraded_mode,
get_source_weights
)
from app.models.energy_score import EnergyScore
from app.schemas.energy_score import (
EnergyScoreCreate,
EnergyScoreUpdate,
EnergyScoreCalculationRequest,
EnergyScoreCalculationResponse
)
from app.database import get_db
def calculate_and_store_energy_score(
db: Session,
request: EnergyScoreCalculationRequest
) -> EnergyScore:
"""
Calculate energy score and store it in the database.
Args:
db: Database session
request: Energy score calculation request
Returns:
Created EnergyScore object
"""
# Calculate energy score using the ML module
result = calculate_energy_score(
match_id=request.match_id,
team_id=request.team_id,
twitter_sentiments=request.twitter_sentiments or [],
reddit_sentiments=request.reddit_sentiments or [],
rss_sentiments=request.rss_sentiments or [],
tweets_with_timestamps=request.tweets_with_timestamps or []
)
# Get adjusted weights for degraded mode tracking
available_sources = result['sources_used']
original_weights = get_source_weights()
adjusted_weights = adjust_weights_for_degraded_mode(
original_weights=original_weights,
available_sources=available_sources
)
# Create energy score record
energy_score = EnergyScore(
match_id=request.match_id,
team_id=request.team_id,
score=result['score'],
confidence=result['confidence'],
sources_used=result['sources_used'],
twitter_score=_calculate_component_score(request.twitter_sentiments),
reddit_score=_calculate_component_score(request.reddit_sentiments),
rss_score=_calculate_component_score(request.rss_sentiments),
temporal_factor=result.get('temporal_factor'),
twitter_weight=adjusted_weights.get('twitter'),
reddit_weight=adjusted_weights.get('reddit'),
rss_weight=adjusted_weights.get('rss'),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Save to database
db.add(energy_score)
db.commit()
db.refresh(energy_score)
return energy_score
def _calculate_component_score(sentiments: Optional[List[Dict]]) -> Optional[float]:
"""
Calculate component score for a single source.
Args:
sentiments: List of sentiment scores
Returns:
Component score or None if no sentiments
"""
if not sentiments:
return None
# Simple average of compound scores
total = sum(s.get('compound', 0) for s in sentiments)
return total / len(sentiments) if sentiments else None
def get_energy_score(
db: Session,
energy_score_id: int
) -> Optional[EnergyScore]:
"""
Get an energy score by ID.
Args:
db: Database session
energy_score_id: ID of the energy score
Returns:
EnergyScore object or None
"""
return db.query(EnergyScore).filter(EnergyScore.id == energy_score_id).first()
def get_energy_scores_by_match(
db: Session,
match_id: int
) -> List[EnergyScore]:
"""
Get all energy scores for a specific match.
Args:
db: Database session
match_id: ID of the match
Returns:
List of EnergyScore objects
"""
return db.query(EnergyScore).filter(EnergyScore.match_id == match_id).all()
def get_energy_scores_by_team(
db: Session,
team_id: int
) -> List[EnergyScore]:
"""
Get all energy scores for a specific team.
Args:
db: Database session
team_id: ID of the team
Returns:
List of EnergyScore objects
"""
return db.query(EnergyScore).filter(EnergyScore.team_id == team_id).all()
def get_energy_score_by_match_and_team(
db: Session,
match_id: int,
team_id: int
) -> Optional[EnergyScore]:
"""
Get the most recent energy score for a specific match and team.
Args:
db: Database session
match_id: ID of the match
team_id: ID of the team
Returns:
EnergyScore object or None
"""
return (db.query(EnergyScore)
.filter(and_(EnergyScore.match_id == match_id, EnergyScore.team_id == team_id))
.order_by(EnergyScore.created_at.desc())
.first())
def update_energy_score(
db: Session,
energy_score_id: int,
update: EnergyScoreUpdate
) -> Optional[EnergyScore]:
"""
Update an existing energy score.
Args:
db: Database session
energy_score_id: ID of the energy score
update: Updated energy score data
Returns:
Updated EnergyScore object or None
"""
energy_score = get_energy_score(db, energy_score_id)
if not energy_score:
return None
# Update fields
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(energy_score, key, value)
energy_score.updated_at = datetime.utcnow()
# Save to database
db.commit()
db.refresh(energy_score)
return energy_score
def delete_energy_score(
db: Session,
energy_score_id: int
) -> bool:
"""
Delete an energy score.
Args:
db: Database session
energy_score_id: ID of the energy score
Returns:
True if deleted, False if not found
"""
energy_score = get_energy_score(db, energy_score_id)
if not energy_score:
return False
db.delete(energy_score)
db.commit()
return True
def list_energy_scores(
db: Session,
match_id: Optional[int] = None,
team_id: Optional[int] = None,
min_score: Optional[float] = None,
max_score: Optional[float] = None,
min_confidence: Optional[float] = None,
limit: int = 10,
offset: int = 0
) -> List[EnergyScore]:
"""
List energy scores with optional filters.
Args:
db: Database session
match_id: Optional filter by match ID
team_id: Optional filter by team ID
min_score: Optional filter by minimum score
max_score: Optional filter by maximum score
min_confidence: Optional filter by minimum confidence
limit: Maximum number of results
offset: Offset for pagination
Returns:
List of EnergyScore objects
"""
query = db.query(EnergyScore)
# Apply filters
if match_id is not None:
query = query.filter(EnergyScore.match_id == match_id)
if team_id is not None:
query = query.filter(EnergyScore.team_id == team_id)
if min_score is not None:
query = query.filter(EnergyScore.score >= min_score)
if max_score is not None:
query = query.filter(EnergyScore.score <= max_score)
if min_confidence is not None:
query = query.filter(EnergyScore.confidence >= min_confidence)
# Apply pagination and ordering
query = query.order_by(EnergyScore.created_at.desc())
query = query.offset(offset).limit(limit)
return query.all()

View File

@@ -0,0 +1,161 @@
"""
Leaderboard Service.
This module handles the calculation and retrieval of user rankings
based on prediction accuracy.
"""
from typing import Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import func, case, desc
from app.models.user import User
from app.models.user_prediction import UserPrediction
class LeaderboardService:
"""Service for managing leaderboard calculations and queries."""
def __init__(self, db: Session):
"""Initialize leaderboard service with database session."""
self.db = db
def get_leaderboard(self, limit: int = 100) -> list[dict]:
"""
Get leaderboard sorted by accuracy (descending) with predictions count as tie-breaker.
Args:
limit: Maximum number of users to return (default: 100)
Returns:
List of dictionaries containing user_id, username, accuracy, predictions_count
"""
# Calculate accuracy for each user who has viewed predictions
# Accuracy = (number of correct predictions / total predictions where was_correct is not NULL) * 100
query = (
self.db.query(
User.id.label('user_id'),
User.name.label('username'),
func.count(UserPrediction.id).label('predictions_count'),
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
).label('correct_count'),
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
).label('completed_predictions_count')
)
.join(UserPrediction, User.id == UserPrediction.user_id)
.filter(
UserPrediction.was_correct.isnot(None) # Only include predictions where match is completed
)
.group_by(User.id, User.name)
.order_by(
desc(
# Calculate accuracy: correct / completed * 100
func.coalesce(
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
) * 100.0 /
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
),
0.0
)
),
desc('predictions_count') # Tie-breaker: more predictions = higher rank
)
.limit(limit)
)
results = query.all()
# Format results
leaderboard = []
for row in results:
completed_count = row.completed_predictions_count or 0
correct_count = row.correct_count or 0
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
leaderboard.append({
'user_id': row.user_id,
'username': row.username,
'accuracy': round(accuracy, 1),
'predictions_count': row.predictions_count
})
return leaderboard
def get_personal_rank(self, user_id: int) -> Optional[dict]:
"""
Get personal rank data for a specific user.
Args:
user_id: ID of the user
Returns:
Dictionary containing rank, accuracy, predictions_count, or None if user not found
"""
# Get user's stats
user_stats = (
self.db.query(
User.id,
func.count(UserPrediction.id).label('predictions_count'),
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
).label('correct_count'),
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
).label('completed_predictions_count')
)
.join(UserPrediction, User.id == UserPrediction.user_id)
.filter(User.id == user_id)
.filter(UserPrediction.was_correct.isnot(None))
.group_by(User.id)
.first()
)
if not user_stats:
return None
completed_count = user_stats.completed_predictions_count or 0
correct_count = user_stats.correct_count or 0
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
predictions_count = user_stats.predictions_count or 0
# Get full leaderboard to calculate rank
full_leaderboard = self.get_leaderboard(limit=1000) # Get more to find rank accurately
# Find user's rank
rank = None
for idx, entry in enumerate(full_leaderboard, start=1):
if entry['user_id'] == user_id:
rank = idx
break
if rank is None:
return None
return {
'rank': rank,
'accuracy': round(accuracy, 1),
'predictions_count': predictions_count
}

View File

@@ -0,0 +1,304 @@
"""
Prediction Service Module.
This module provides business logic for creating and managing match predictions.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from app.ml.prediction_calculator import calculate_prediction, validate_prediction_result
from app.models.match import Match
from app.models.prediction import Prediction
from app.schemas.prediction import PredictionCreate, MatchInfo
class PredictionService:
"""Service for handling prediction business logic."""
def __init__(self, db: Session):
"""
Initialize the prediction service.
Args:
db: SQLAlchemy database session
"""
self.db = db
def create_prediction_for_match(
self,
match_id: int,
home_energy: float,
away_energy: float,
energy_score_label: Optional[str] = None
) -> Prediction:
"""
Create a prediction for a specific match based on energy scores.
This method:
1. Validates that the match exists
2. Calculates the prediction using energy scores
3. Stores the prediction in the database
4. Returns the created prediction
Args:
match_id: ID of the match to predict
home_energy: Energy score of the home team
away_energy: Energy score of the away team
energy_score_label: Optional label for energy score (e.g., "high", "medium", "low")
Returns:
Created Prediction object
Raises:
ValueError: If match doesn't exist or energy scores are invalid
"""
# Validate match exists
match = self.db.query(Match).filter(Match.id == match_id).first()
if not match:
raise ValueError(f"Match with id {match_id} not found")
# Validate energy scores
if not isinstance(home_energy, (int, float)) or not isinstance(away_energy, (int, float)):
raise ValueError("Energy scores must be numeric values")
if home_energy < 0 or away_energy < 0:
raise ValueError("Energy scores cannot be negative")
# Calculate prediction
prediction_result = calculate_prediction(home_energy, away_energy)
# Validate prediction result
if not validate_prediction_result(prediction_result):
raise ValueError("Invalid prediction calculation result")
# Determine energy score label if not provided
if energy_score_label is None:
avg_energy = (home_energy + away_energy) / 2
if avg_energy >= 70:
energy_score_label = "very_high"
elif avg_energy >= 50:
energy_score_label = "high"
elif avg_energy >= 30:
energy_score_label = "medium"
else:
energy_score_label = "low"
# Determine predicted winner team name
if prediction_result['predicted_winner'] == 'home':
predicted_winner_name = match.home_team
elif prediction_result['predicted_winner'] == 'away':
predicted_winner_name = match.away_team
else:
predicted_winner_name = "Draw"
# Create prediction object
prediction = Prediction(
match_id=match_id,
energy_score=energy_score_label,
confidence=f"{prediction_result['confidence']:.1f}%",
predicted_winner=predicted_winner_name,
created_at=datetime.now(timezone.utc)
)
# Save to database
self.db.add(prediction)
self.db.commit()
self.db.refresh(prediction)
return prediction
def get_prediction_by_id(self, prediction_id: int) -> Optional[Prediction]:
"""
Get a prediction by its ID.
Args:
prediction_id: ID of the prediction to retrieve
Returns:
Prediction object or None if not found
"""
return self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
def get_predictions_for_match(self, match_id: int) -> list[Prediction]:
"""
Get all predictions for a specific match.
Args:
match_id: ID of the match
Returns:
List of Prediction objects
"""
return self.db.query(Prediction).filter(Prediction.match_id == match_id).all()
def get_latest_prediction_for_match(self, match_id: int) -> Optional[Prediction]:
"""
Get the most recent prediction for a match.
Args:
match_id: ID of the match
Returns:
Latest Prediction object or None if no predictions exist
"""
return (
self.db.query(Prediction)
.filter(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.first()
)
def delete_prediction(self, prediction_id: int) -> bool:
"""
Delete a prediction by its ID.
Args:
prediction_id: ID of the prediction to delete
Returns:
True if deleted, False if not found
"""
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if prediction:
self.db.delete(prediction)
self.db.commit()
return True
return False
def get_predictions_with_pagination(
self,
limit: int = 20,
offset: int = 0,
team_id: Optional[int] = None,
league: Optional[str] = None,
date_min: Optional[datetime] = None,
date_max: Optional[datetime] = None
) -> tuple[list[Prediction], int]:
"""
Get predictions with pagination and filters.
This method retrieves predictions joined with match data, applies filters,
and returns paginated results.
Args:
limit: Maximum number of predictions to return (max 100)
offset: Number of predictions to skip
team_id: Optional filter by team ID (home or away)
league: Optional filter by league name
date_min: Optional filter for matches after this date
date_max: Optional filter for matches before this date
Returns:
Tuple of (list of predictions, total count)
"""
# Start with a query that includes match data
query = (
self.db.query(Prediction)
.join(Match)
)
# Apply filters
if team_id:
# Get the match for the team_id to get team names
team_match = self.db.query(Match).filter(Match.id == team_id).first()
if team_match:
query = query.filter(
(Match.home_team == team_match.home_team) |
(Match.away_team == team_match.away_team)
)
if league:
query = query.filter(Match.league.ilike(f"%{league}%"))
if date_min:
query = query.filter(Match.date >= date_min)
if date_max:
query = query.filter(Match.date <= date_max)
# Get total count before pagination
total = query.count()
# Apply pagination and ordering by match date (upcoming matches first)
predictions = (
query
.order_by(Match.date.asc())
.limit(min(limit, 100))
.offset(offset)
.all()
)
return predictions, total
def get_prediction_with_details(self, match_id: int) -> Optional[dict]:
"""
Get a prediction for a specific match with full details.
This method retrieves latest prediction for a match and includes
match details, energy score information, and historical data.
Args:
match_id: ID of match
Returns:
Dictionary with prediction details or None if not found
"""
prediction = self.get_latest_prediction_for_match(match_id)
if not prediction:
return None
# Get match details
match = prediction.match
# Build response with all details
result = {
"id": prediction.id,
"match_id": prediction.match_id,
"match": {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status,
"actual_winner": match.actual_winner
},
"energy_score": prediction.energy_score,
"confidence": prediction.confidence,
"predicted_winner": prediction.predicted_winner,
"created_at": prediction.created_at.isoformat() if prediction.created_at else None,
"history": self._get_prediction_history(match_id)
}
return result
def _get_prediction_history(self, match_id: int) -> list[dict]:
"""
Get historical predictions for a match.
Args:
match_id: ID of match
Returns:
List of historical predictions (all predictions for match)
"""
predictions = (
self.db.query(Prediction)
.filter(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.all()
)
return [
{
"id": pred.id,
"energy_score": pred.energy_score,
"confidence": pred.confidence,
"predicted_winner": pred.predicted_winner,
"created_at": pred.created_at.isoformat() if pred.created_at else None
}
for pred in predictions
]

View File

@@ -0,0 +1,326 @@
"""
Sentiment Analysis Service
This module provides services for batch processing of tweets and posts,
storing sentiment scores in the database, and calculating aggregated metrics.
"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.ml.sentiment_analyzer import (
analyze_sentiment,
analyze_sentiment_batch,
calculate_aggregated_metrics
)
from app.models.sentiment_score import SentimentScore
from app.models.tweet import Tweet
from app.models.reddit_post import RedditPost
def process_tweet_sentiment(
db: Session,
tweet_id: str,
text: str
) -> SentimentScore:
"""
Analyze sentiment for a single tweet and store in database.
Args:
db: Database session
tweet_id: Tweet identifier
text: Tweet text to analyze
Returns:
Created SentimentScore record
"""
# Analyze sentiment
sentiment_result = analyze_sentiment(text)
# Create database record
sentiment_score = SentimentScore(
entity_id=tweet_id,
entity_type='tweet',
score=sentiment_result['compound'],
sentiment_type=sentiment_result['sentiment'],
positive=sentiment_result['positive'],
negative=sentiment_result['negative'],
neutral=sentiment_result['neutral']
)
db.add(sentiment_score)
db.commit()
db.refresh(sentiment_score)
return sentiment_score
def process_tweet_batch(
db: Session,
tweets: List[Tweet]
) -> List[SentimentScore]:
"""
Analyze sentiment for a batch of tweets and store in database.
Args:
db: Database session
tweets: List of Tweet models to analyze
Returns:
List of created SentimentScore records
"""
if not tweets:
return []
# Extract texts
texts = [tweet.text for tweet in tweets]
tweet_ids = [tweet.tweet_id for tweet in tweets]
# Analyze in batch
sentiment_results = analyze_sentiment_batch(texts)
# Create database records
sentiment_scores = []
for tweet_id, result in zip(tweet_ids, sentiment_results):
sentiment_score = SentimentScore(
entity_id=tweet_id,
entity_type='tweet',
score=result['compound'],
sentiment_type=result['sentiment'],
positive=result['positive'],
negative=result['negative'],
neutral=result['neutral']
)
sentiment_scores.append(sentiment_score)
# Batch insert
db.add_all(sentiment_scores)
db.commit()
# Refresh to get IDs
for score in sentiment_scores:
db.refresh(score)
return sentiment_scores
def process_reddit_post_sentiment(
db: Session,
post_id: str,
text: str
) -> SentimentScore:
"""
Analyze sentiment for a single Reddit post and store in database.
Args:
db: Database session
post_id: Reddit post identifier
text: Post text to analyze
Returns:
Created SentimentScore record
"""
# Analyze sentiment
sentiment_result = analyze_sentiment(text)
# Create database record
sentiment_score = SentimentScore(
entity_id=post_id,
entity_type='reddit_post',
score=sentiment_result['compound'],
sentiment_type=sentiment_result['sentiment'],
positive=sentiment_result['positive'],
negative=sentiment_result['negative'],
neutral=sentiment_result['neutral']
)
db.add(sentiment_score)
db.commit()
db.refresh(sentiment_score)
return sentiment_score
def process_reddit_post_batch(
db: Session,
posts: List[RedditPost]
) -> List[SentimentScore]:
"""
Analyze sentiment for a batch of Reddit posts and store in database.
Args:
db: Database session
posts: List of RedditPost models to analyze
Returns:
List of created SentimentScore records
"""
if not posts:
return []
# Extract texts (combine title and text if available)
texts = []
post_ids = []
for post in posts:
text = post.text if post.text else ""
full_text = f"{post.title} {text}"
texts.append(full_text)
post_ids.append(post.post_id)
# Analyze in batch
sentiment_results = analyze_sentiment_batch(texts)
# Create database records
sentiment_scores = []
for post_id, result in zip(post_ids, sentiment_results):
sentiment_score = SentimentScore(
entity_id=post_id,
entity_type='reddit_post',
score=result['compound'],
sentiment_type=result['sentiment'],
positive=result['positive'],
negative=result['negative'],
neutral=result['neutral']
)
sentiment_scores.append(sentiment_score)
# Batch insert
db.add_all(sentiment_scores)
db.commit()
# Refresh to get IDs
for score in sentiment_scores:
db.refresh(score)
return sentiment_scores
def get_sentiment_by_entity(
db: Session,
entity_id: str,
entity_type: str
) -> Optional[SentimentScore]:
"""
Retrieve sentiment score for a specific entity.
Args:
db: Database session
entity_id: Entity identifier
entity_type: Entity type ('tweet' or 'reddit_post')
Returns:
SentimentScore if found, None otherwise
"""
return db.query(SentimentScore).filter(
SentimentScore.entity_id == entity_id,
SentimentScore.entity_type == entity_type
).first()
def get_sentiments_by_match(
db: Session,
match_id: int
) -> List[SentimentScore]:
"""
Retrieve all sentiment scores for a specific match.
Args:
db: Database session
match_id: Match identifier
Returns:
List of SentimentScore records for the match
"""
# Join with tweets table to filter by match_id
return db.query(SentimentScore).join(
Tweet, Tweet.tweet_id == SentimentScore.entity_id
).filter(
Tweet.match_id == match_id,
SentimentScore.entity_type == 'tweet'
).all()
def calculate_match_sentiment_metrics(
db: Session,
match_id: int
) -> Dict:
"""
Calculate aggregated sentiment metrics for a match.
Args:
db: Database session
match_id: Match identifier
Returns:
Dictionary with aggregated metrics
"""
# Get all sentiments for the match
sentiments = get_sentiments_by_match(db, match_id)
if not sentiments:
return {
'match_id': match_id,
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'positive_ratio': 0.0,
'negative_ratio': 0.0,
'neutral_ratio': 0.0,
'average_compound': 0.0
}
# Convert to list of dicts for calculate_aggregated_metrics
sentiment_dicts = [
{
'compound': s.score,
'sentiment': s.sentiment_type
}
for s in sentiments
]
# Calculate metrics
metrics = calculate_aggregated_metrics(sentiment_dicts)
metrics['match_id'] = match_id
return metrics
def get_global_sentiment_metrics(
db: Session
) -> Dict:
"""
Calculate global sentiment metrics across all entities.
Args:
db: Database session
Returns:
Dictionary with global aggregated metrics
"""
# Get all sentiment scores
all_sentiments = db.query(SentimentScore).all()
if not all_sentiments:
return {
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'positive_ratio': 0.0,
'negative_ratio': 0.0,
'neutral_ratio': 0.0,
'average_compound': 0.0
}
# Convert to list of dicts
sentiment_dicts = [
{
'compound': s.score,
'sentiment': s.sentiment_type
}
for s in all_sentiments
]
# Calculate metrics
return calculate_aggregated_metrics(sentiment_dicts)

View File

@@ -0,0 +1,216 @@
"""
User Prediction Service.
This module provides business logic for tracking user predictions,
calculating ROI and accuracy rates.
"""
from datetime import datetime
from typing import List, Tuple, Optional
from sqlalchemy.orm import Session
from app.models import User, UserPrediction, Prediction, Match
class UserPredictionService:
"""
Service for managing user predictions and statistics.
This service handles:
- Tracking which predictions users have viewed
- Updating prediction results when matches complete
- Calculating user accuracy rates
- Calculating user ROI (Return on Investment)
"""
def __init__(self, db: Session):
"""Initialize service with database session."""
self.db = db
def record_prediction_view(self, user_id: int, prediction_id: int) -> UserPrediction:
"""
Record that a user viewed a prediction.
Args:
user_id: ID of the user
prediction_id: ID of the prediction viewed
Returns:
Created or existing UserPrediction record
Raises:
ValueError: If user or prediction doesn't exist
"""
# Check if user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"User with id {user_id} not found")
# Check if prediction exists
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if not prediction:
raise ValueError(f"Prediction with id {prediction_id} not found")
# Check if already viewed (unique constraint)
existing = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id,
UserPrediction.prediction_id == prediction_id
).first()
if existing:
return existing
# Create new record
user_prediction = UserPrediction(
user_id=user_id,
prediction_id=prediction_id,
viewed_at=datetime.utcnow(),
was_correct=None # Will be set when match completes
)
self.db.add(user_prediction)
self.db.commit()
self.db.refresh(user_prediction)
return user_prediction
def get_user_prediction_history(
self,
user_id: int,
limit: int = 50,
offset: int = 0
) -> Tuple[List[dict], int]:
"""
Get a user's prediction viewing history.
Args:
user_id: ID of the user
limit: Maximum number of records to return
offset: Number of records to skip
Returns:
Tuple of (list of user predictions with details, total count)
"""
query = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).order_by(UserPrediction.viewed_at.desc())
total = query.count()
user_predictions = query.limit(limit).offset(offset).all()
# Build response with full details
result = []
for up in user_predictions:
pred = up.prediction
match = pred.match if pred else None
result.append({
'id': up.id,
'user_id': up.user_id,
'prediction_id': up.prediction_id,
'viewed_at': up.viewed_at.isoformat() if up.viewed_at else None,
'was_correct': up.was_correct,
'prediction': {
'id': pred.id if pred else None,
'match_id': pred.match_id if pred else None,
'energy_score': pred.energy_score if pred else None,
'confidence': pred.confidence if pred else None,
'predicted_winner': pred.predicted_winner if pred else None,
'created_at': pred.created_at.isoformat() if pred and pred.created_at else None
} if pred else None,
'match': {
'id': match.id if match else None,
'home_team': match.home_team if match else None,
'away_team': match.away_team if match else None,
'date': match.date.isoformat() if match and match.date else None,
'league': match.league if match else None,
'status': match.status if match else None,
'actual_winner': match.actual_winner if match else None
} if match else None
})
return result, total
def update_prediction_result(self, prediction_id: int, actual_winner: Optional[str]) -> None:
"""
Update all user predictions for a given prediction with match result.
This is called when a match completes to mark which predictions were correct.
Args:
prediction_id: ID of the prediction
actual_winner: Actual winner of the match ("home", "away", "draw", or None)
"""
# Get prediction
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if not prediction:
return
# Update match result
match = self.db.query(Match).filter(Match.id == prediction.match_id).first()
if match:
match.actual_winner = actual_winner
# Determine if prediction was correct
was_correct = False
if actual_winner:
# Normalize winner comparison
predicted = prediction.predicted_winner.lower()
actual = actual_winner.lower()
if predicted == actual:
was_correct = True
elif (predicted == 'home' and actual == 'home_team') or \
(predicted == 'away' and actual == 'away_team'):
was_correct = True
# Update all user predictions for this prediction
user_predictions = self.db.query(UserPrediction).filter(
UserPrediction.prediction_id == prediction_id
).all()
for up in user_predictions:
up.was_correct = was_correct
self.db.commit()
def get_user_stats(self, user_id: int) -> dict:
"""
Calculate user statistics including accuracy and ROI.
Args:
user_id: ID of the user
Returns:
Dictionary with statistics:
- total_predictions_viewed: Total predictions viewed
- correct_predictions: Number of correct predictions
- incorrect_predictions: Number of incorrect predictions
- accuracy_rate: Accuracy as percentage
- roi: Return on Investment in EUR
"""
user_predictions = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).all()
total = len(user_predictions)
correct = sum(1 for up in user_predictions if up.was_correct is True)
incorrect = sum(1 for up in user_predictions if up.was_correct is False)
# Calculate accuracy
accuracy_rate = 0.0
if correct + incorrect > 0:
accuracy_rate = (correct / (correct + incorrect)) * 100
# Calculate ROI
# Assumptions: Each correct prediction = +100€, Each incorrect = -50€
# This is a simplified model - can be adjusted based on actual betting rules
roi = (correct * 100) - (incorrect * 50)
return {
'total_predictions_viewed': total,
'correct_predictions': correct,
'incorrect_predictions': incorrect,
'accuracy_rate': round(accuracy_rate, 1),
'roi': roi
}