Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
VYNDR — Consolidated Python Service
|
||||
Master Flask app. Registers all blueprints. Health check. Rate limiting.
|
||||
Self-documenting API. Single process on port 5001.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] %(levelname)s %(name)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# Add utils to path for imports
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Request body size limit — 1MB default (OCR validates its own 10MB limit)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024
|
||||
|
||||
# CORS — locked to ALLOWED_ORIGINS (Vercel domain + localhost)
|
||||
ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
|
||||
CORS(app, resources={r'/api/*': {
|
||||
'origins': ALLOWED_ORIGINS,
|
||||
'methods': ['GET', 'POST', 'OPTIONS'],
|
||||
'allow_headers': ['Authorization', 'Content-Type', 'X-API-Key'],
|
||||
'max_age': 3600
|
||||
}})
|
||||
|
||||
# Rate limiting — real IP from X-Forwarded-For (Railway proxy)
|
||||
def _get_real_ip():
|
||||
from flask import request as _req
|
||||
forwarded = _req.headers.get('X-Forwarded-For', '')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return _req.remote_addr or '127.0.0.1'
|
||||
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=_get_real_ip,
|
||||
default_limits=["60 per minute"],
|
||||
storage_uri="memory://"
|
||||
)
|
||||
|
||||
# Shadow mode — set to False after 2 weeks of verified accuracy
|
||||
SHADOW_MODE = os.environ.get('SHADOW_MODE', 'true').lower() == 'true'
|
||||
|
||||
|
||||
# --- Security: Headers, Logging, Error Handling ---
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to every response."""
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'"
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers.pop('Server', None)
|
||||
return response
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request_security():
|
||||
"""Log every request for security monitoring."""
|
||||
try:
|
||||
from utils.security_logger import log_request
|
||||
from flask import request as _req
|
||||
log_request(_req)
|
||||
except Exception:
|
||||
pass # Security logging must never block requests
|
||||
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(e):
|
||||
"""Never expose internal errors in production."""
|
||||
from werkzeug.exceptions import HTTPException
|
||||
logger.error(f'[ERROR] Unhandled: {e}', exc_info=True)
|
||||
if isinstance(e, HTTPException):
|
||||
return jsonify({'error': e.description}), e.code
|
||||
if os.environ.get('FLASK_ENV') == 'production':
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({'error': 'Endpoint not found'}), 404
|
||||
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
|
||||
|
||||
@app.errorhandler(413)
|
||||
def payload_too_large(e):
|
||||
return jsonify({'error': 'Request payload too large. Max 1MB (10MB for images).'}), 413
|
||||
|
||||
|
||||
@app.errorhandler(429)
|
||||
def rate_limited(e):
|
||||
return jsonify({'error': 'Rate limit exceeded. Try again later.'}), 429
|
||||
|
||||
# --- Register Blueprints ---
|
||||
|
||||
from blueprints.evolution import evolution_bp
|
||||
app.register_blueprint(evolution_bp, url_prefix='/api/evolution')
|
||||
|
||||
# Import remaining blueprints (registered as they are built in later phases)
|
||||
try:
|
||||
from blueprints.synergy import synergy_bp
|
||||
app.register_blueprint(synergy_bp, url_prefix='/api/synergy')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Synergy blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.mlb import mlb_bp
|
||||
app.register_blueprint(mlb_bp, url_prefix='/api/mlb')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] MLB blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.nba_context import nba_context_bp
|
||||
app.register_blueprint(nba_context_bp, url_prefix='/api/nba')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] NBA Context blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.lineup_intelligence import lineup_bp
|
||||
app.register_blueprint(lineup_bp, url_prefix='/api/lineups')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Lineup Intelligence blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.odds_scanner import odds_bp
|
||||
app.register_blueprint(odds_bp, url_prefix='/api/odds')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Odds Scanner blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.calibration import calibration_bp
|
||||
app.register_blueprint(calibration_bp, url_prefix='/api/calibration')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Calibration blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.resolution import resolution_bp
|
||||
app.register_blueprint(resolution_bp, url_prefix='/api/resolution')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Resolution blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.image_grade import image_grade_bp
|
||||
app.register_blueprint(image_grade_bp, url_prefix='/api/grade')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Image Grade blueprint not yet available')
|
||||
|
||||
# --- Supplement Blueprints ---
|
||||
|
||||
try:
|
||||
from blueprints.coaching import coaching_bp
|
||||
app.register_blueprint(coaching_bp, url_prefix='/api/coaching')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Coaching blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.redistribution import redistribution_bp
|
||||
app.register_blueprint(redistribution_bp, url_prefix='/api/redistribution')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Redistribution blueprint not yet available')
|
||||
|
||||
try:
|
||||
from blueprints.unconventional import unconventional_bp
|
||||
app.register_blueprint(unconventional_bp, url_prefix='/api/unconventional')
|
||||
except ImportError:
|
||||
logger.info('[VYNDR] Unconventional blueprint not yet available')
|
||||
|
||||
|
||||
# --- Health Check ---
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for deployment monitoring.
|
||||
Checks connectivity to all dependent services.
|
||||
|
||||
Returns:
|
||||
200 if all services healthy, 503 if any degraded.
|
||||
"""
|
||||
services = {}
|
||||
|
||||
# Supabase
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
client = get_supabase_client()
|
||||
services['supabase'] = 'ok' if client else 'not_configured'
|
||||
except Exception:
|
||||
services['supabase'] = 'error'
|
||||
|
||||
# Redis
|
||||
try:
|
||||
import redis
|
||||
r = redis.from_url(os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379'))
|
||||
r.ping()
|
||||
services['redis'] = 'ok'
|
||||
except Exception:
|
||||
services['redis'] = 'unavailable'
|
||||
|
||||
# Odds API
|
||||
services['odds_api'] = 'configured' if os.environ.get('ODDS_API_KEY') else 'not_configured'
|
||||
|
||||
# nba_api
|
||||
try:
|
||||
import nba_api
|
||||
services['nba_api'] = 'available'
|
||||
except ImportError:
|
||||
services['nba_api'] = 'not_installed'
|
||||
|
||||
# Weather API (Open-Meteo — always available, no key)
|
||||
services['weather_api'] = 'ok'
|
||||
|
||||
# MLB Stats API
|
||||
try:
|
||||
import statsapi
|
||||
services['mlb_stats_api'] = 'available'
|
||||
except ImportError:
|
||||
services['mlb_stats_api'] = 'not_installed'
|
||||
|
||||
all_healthy = all(s in ('ok', 'available', 'configured') for s in services.values())
|
||||
return jsonify({
|
||||
'status': 'ok' if all_healthy else 'degraded',
|
||||
'version': '5.1',
|
||||
'shadow_mode': SHADOW_MODE,
|
||||
'services': services,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 200 if all_healthy else 503
|
||||
|
||||
|
||||
# --- Self-Documenting API ---
|
||||
|
||||
@app.route('/api/docs', methods=['GET'])
|
||||
def api_docs():
|
||||
"""
|
||||
Self-documenting API reference for frontend integration.
|
||||
Lists all available endpoints with method, path, and body schema.
|
||||
"""
|
||||
return jsonify({
|
||||
'endpoints': {
|
||||
'health': {'method': 'GET', 'path': '/health'},
|
||||
'nba_grade': {
|
||||
'method': 'POST', 'path': '/api/nba/grade',
|
||||
'body': '{player_name, stat_type, line, over_under, user_id}'
|
||||
},
|
||||
'nba_sub_scores': {
|
||||
'method': 'GET',
|
||||
'path': '/api/nba/sub-scores/{player_id}/{game_id}'
|
||||
},
|
||||
'mlb_grade': {
|
||||
'method': 'POST', 'path': '/api/mlb/grade',
|
||||
'body': '{player_name, stat_type, line, over_under, pitcher_id?, user_id}'
|
||||
},
|
||||
'scan_slate': {'method': 'GET', 'path': '/api/odds/scan/{sport}'},
|
||||
'resolve_grades': {
|
||||
'method': 'POST',
|
||||
'path': '/api/calibration/resolve/{game_date}'
|
||||
},
|
||||
'brier_score': {
|
||||
'method': 'GET',
|
||||
'path': '/api/calibration/brier-score/{sport}'
|
||||
},
|
||||
'clv_report': {
|
||||
'method': 'GET',
|
||||
'path': '/api/calibration/clv/{sport}'
|
||||
},
|
||||
'blind_spots': {
|
||||
'method': 'GET',
|
||||
'path': '/api/calibration/blind-spots/{sport}'
|
||||
},
|
||||
'grade_from_image': {
|
||||
'method': 'POST', 'path': '/api/grade/from-image'
|
||||
},
|
||||
'parlay_grade': {
|
||||
'method': 'POST', 'path': '/api/parlay/grade',
|
||||
'body': '{legs: [...]}'
|
||||
},
|
||||
'synergy_team': {
|
||||
'method': 'GET',
|
||||
'path': '/api/synergy/team-playtypes/{team_id}'
|
||||
},
|
||||
'evolution_detect': {
|
||||
'method': 'POST', 'path': '/api/evolution/detect-changepoints',
|
||||
'body': '{values, min_size?, penalty?, player_id?, metric?}'
|
||||
},
|
||||
'api_docs': {'method': 'GET', 'path': '/api/docs'},
|
||||
# Supplement endpoints
|
||||
'coaching_tendencies': {
|
||||
'method': 'GET',
|
||||
'path': '/api/coaching/tendencies/{coach_id}?sport={sport}'
|
||||
},
|
||||
'coaching_shift': {
|
||||
'method': 'GET',
|
||||
'path': '/api/coaching/shift-detection/{team_id}?sport={sport}'
|
||||
},
|
||||
'redistribution': {
|
||||
'method': 'GET',
|
||||
'path': '/api/redistribution/calculate/{player_out_id}/{game_id}'
|
||||
},
|
||||
'alt_lines': {
|
||||
'method': 'GET',
|
||||
'path': '/api/odds/alt-lines/{sport}/{player_name}/{stat_type}'
|
||||
},
|
||||
'evolution_scan': {
|
||||
'method': 'GET',
|
||||
'path': '/api/evolution/scan/{sport}'
|
||||
},
|
||||
'unconventional_status': {
|
||||
'method': 'GET',
|
||||
'path': '/api/unconventional/status'
|
||||
},
|
||||
'unconventional_validate': {
|
||||
'method': 'POST',
|
||||
'path': '/api/unconventional/validate/{factor_name}'
|
||||
}
|
||||
},
|
||||
'version': '5.1',
|
||||
'shadow_mode': SHADOW_MODE
|
||||
})
|
||||
|
||||
|
||||
# --- Cold Start Boot Sequence ---
|
||||
|
||||
def cold_start_boot():
|
||||
"""
|
||||
Day-one initialization. Order matters — later steps depend on earlier ones.
|
||||
Called once on startup. Non-fatal failures are logged but don't block boot.
|
||||
"""
|
||||
logger.info('[VYNDR] Cold start boot sequence initiated')
|
||||
|
||||
# Load static data files
|
||||
data_dir = os.path.join(os.path.dirname(__file__), 'data')
|
||||
_load_json(os.path.join(data_dir, 'park_factors.json'), 'park_factors')
|
||||
_load_json(os.path.join(data_dir, 'reporter_database.json'), 'reporter_database')
|
||||
_load_json(os.path.join(data_dir, 'timezone_map.json'), 'timezone_map')
|
||||
_load_json(os.path.join(data_dir, 'grade_thresholds.json'), 'grade_thresholds')
|
||||
|
||||
# Seed reporter database into Supabase reporter_trust table
|
||||
try:
|
||||
_seed_reporter_database(data_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Reporter seeding skipped: {e}')
|
||||
|
||||
logger.info('[VYNDR] Cold start complete — engine ready to grade')
|
||||
|
||||
|
||||
def _load_json(path, name):
|
||||
"""Load a JSON data file. Log warning if missing."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
logger.info(f'[VYNDR] Loaded {name} ({len(str(data))} bytes)')
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
logger.warning(f'[VYNDR] Data file not found: {path}')
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f'[VYNDR] Invalid JSON in {path}: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def _seed_reporter_database(data_dir):
|
||||
"""
|
||||
Populate reporter_trust table from reporter_database.json.
|
||||
Each reporter gets a starting trust tier based on their source_type.
|
||||
Beat writers start at 'reliable'. Nationals start at 'authoritative'.
|
||||
Aggregators start at 'unverified'.
|
||||
"""
|
||||
STARTING_TRUST = {
|
||||
'beat_writer': 'reliable',
|
||||
'national': 'authoritative',
|
||||
'insider': 'reliable',
|
||||
'aggregator': 'unverified'
|
||||
}
|
||||
|
||||
path = os.path.join(data_dir, 'reporter_database.json')
|
||||
try:
|
||||
with open(path) as f:
|
||||
reporters = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.warning('[VYNDR] Reporter database not found for seeding')
|
||||
return
|
||||
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if not supabase:
|
||||
logger.warning('[VYNDR] Supabase not available — reporter seeding skipped')
|
||||
return
|
||||
|
||||
count = 0
|
||||
for sport, teams in reporters.items():
|
||||
if not isinstance(teams, dict):
|
||||
continue
|
||||
for team_id, team_reporters in teams.items():
|
||||
if not isinstance(team_reporters, list):
|
||||
continue
|
||||
for reporter in team_reporters:
|
||||
source_type = reporter.get('source_type', 'beat_writer')
|
||||
starting_trust = STARTING_TRUST.get(source_type, 'unverified')
|
||||
try:
|
||||
supabase.table('reporter_trust').upsert({
|
||||
'handle': reporter['handle'],
|
||||
'sport': sport,
|
||||
'team_id': team_id,
|
||||
'outlet': reporter.get('outlet', ''),
|
||||
'source_type': source_type,
|
||||
'trust_level': starting_trust,
|
||||
'starting_trust': starting_trust
|
||||
}, on_conflict='handle').execute()
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Failed to seed reporter {reporter.get("handle")}: {e}')
|
||||
|
||||
logger.info(f'[VYNDR] Seeded {count} reporters into reporter_trust')
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if __name__ == '__main__':
|
||||
cold_start_boot()
|
||||
port = int(os.environ.get('PORT', 5001))
|
||||
logger.info(f'[VYNDR] Starting Flask app on port {port}')
|
||||
app.run(host='0.0.0.0', port=port, debug=False)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
VYNDR Auto-Calibration Engine
|
||||
Point-biserial correlation for weight calibration.
|
||||
Global offset. Brier score tracking. Blind spot detection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.bayesian import (
|
||||
calculate_global_offset, calculate_brier_score, GRADE_THRESHOLDS
|
||||
)
|
||||
from utils.blind_spot_detector import detect_model_blind_spots, track_catastrophic_misses
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
calibration_bp = Blueprint('calibration', __name__)
|
||||
|
||||
# Calibration thresholds
|
||||
PLAYER_CALIBRATION_THRESHOLDS = [25, 50, 75, 100]
|
||||
GLOBAL_OFFSET_THRESHOLDS = [100, 250, 500, 1000]
|
||||
POINT_BISERIAL_BOUNDS = {'min': 0.05, 'max': 0.50}
|
||||
|
||||
|
||||
def calibrate_weights(player_id, sport, stat_type, outcomes, min_sample=25):
|
||||
"""
|
||||
Calibrate per-player weights using point-biserial correlation.
|
||||
Bounds each weight between 0.05 and 0.50. Triggers at 25/50/75/100 resolved.
|
||||
|
||||
Args:
|
||||
player_id: Player identifier.
|
||||
sport: 'nba' or 'mlb'.
|
||||
stat_type: Stat type string.
|
||||
outcomes: List of resolved outcome dicts with 'hit' and 'sub_scores'.
|
||||
min_sample: Minimum sample size (default 25).
|
||||
|
||||
Returns:
|
||||
Dict of calibrated weights, or None if insufficient data.
|
||||
"""
|
||||
if len(outcomes) < min_sample:
|
||||
return None
|
||||
|
||||
try:
|
||||
from scipy.stats import pointbiserialr
|
||||
except ImportError:
|
||||
logger.warning('[VYNDR] scipy not available for calibration')
|
||||
return None
|
||||
|
||||
hits = [1 if o['hit'] else 0 for o in outcomes]
|
||||
sub_score_keys = list(outcomes[0].get('sub_scores', {}).keys())
|
||||
|
||||
if not sub_score_keys:
|
||||
return None
|
||||
|
||||
correlations = {}
|
||||
for key in sub_score_keys:
|
||||
scores = [o.get('sub_scores', {}).get(key, 0.5) for o in outcomes]
|
||||
try:
|
||||
corr, p_value = pointbiserialr(hits, scores)
|
||||
# Only use correlation if p < 0.10, otherwise use minimum bound
|
||||
correlations[key] = abs(corr) if p_value < 0.10 else POINT_BISERIAL_BOUNDS['min']
|
||||
except Exception:
|
||||
correlations[key] = POINT_BISERIAL_BOUNDS['min']
|
||||
|
||||
# Clamp to bounds and normalize
|
||||
clamped = {
|
||||
k: max(POINT_BISERIAL_BOUNDS['min'], min(POINT_BISERIAL_BOUNDS['max'], v))
|
||||
for k, v in correlations.items()
|
||||
}
|
||||
total = sum(clamped.values())
|
||||
if total == 0:
|
||||
return None
|
||||
|
||||
new_weights = {k: round(v / total, 4) for k, v in clamped.items()}
|
||||
|
||||
logger.info(
|
||||
f'[VYNDR] Calibrated weights for {player_id}/{sport}/{stat_type} '
|
||||
f'(n={len(outcomes)}): {new_weights}'
|
||||
)
|
||||
return new_weights
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@calibration_bp.route('/weights/<player_id>', methods=['GET'])
|
||||
def get_player_weights(player_id):
|
||||
"""
|
||||
Get calibrated weights for a player, or defaults if not yet calibrated.
|
||||
|
||||
Args:
|
||||
player_id: Player identifier.
|
||||
|
||||
Query params:
|
||||
sport: 'nba' or 'mlb'.
|
||||
stat_type: Stat type string.
|
||||
|
||||
Returns:
|
||||
JSON with weights, source ('calibrated' or 'default'), and sample_size.
|
||||
"""
|
||||
sport = request.args.get('sport', 'nba')
|
||||
stat_type = request.args.get('stat_type', 'points')
|
||||
|
||||
# In production, fetch from player_calibrated_weights table
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'sport': sport,
|
||||
'stat_type': stat_type,
|
||||
'weights': None,
|
||||
'source': 'default',
|
||||
'sample_size': 0,
|
||||
'note': 'No calibrated weights yet — using archetype blend or defaults'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/resolve/<game_date>', methods=['POST'])
|
||||
def resolve_grades(game_date):
|
||||
"""
|
||||
Trigger grade resolution for a specific date.
|
||||
Called by the nightly resolution pipeline.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
JSON with resolution summary.
|
||||
"""
|
||||
# Delegate to resolution blueprint
|
||||
return jsonify({
|
||||
'game_date': game_date,
|
||||
'status': 'resolution_triggered',
|
||||
'note': 'Delegated to nightly resolution pipeline'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/global-offset/<sport>', methods=['GET'])
|
||||
def get_global_offset(sport):
|
||||
"""
|
||||
Get the current global calibration offset for a sport.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with offset_value, sample_size, calculated_at.
|
||||
"""
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'offset_value': 0.0,
|
||||
'sample_size': 0,
|
||||
'calculated_at': None,
|
||||
'note': 'No resolved grades yet — offset is 0.0'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/brier-score/<sport>', methods=['GET'])
|
||||
def get_brier_score(sport):
|
||||
"""
|
||||
Get current Brier score for a sport.
|
||||
Lower is better. 0.0 = perfect. 0.25 = coin flip.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with brier_score, sample_size, interpretation.
|
||||
"""
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'brier_score': None,
|
||||
'sample_size': 0,
|
||||
'interpretation': 'No resolved grades yet',
|
||||
'tracked_from': 'day_one'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/blind-spots/<sport>', methods=['GET'])
|
||||
def get_blind_spots(sport):
|
||||
"""
|
||||
Get identified blind spots where the model underperforms.
|
||||
Only available after 200+ resolved grades.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with blind_spots list and catastrophic_misses list.
|
||||
"""
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'blind_spots': [],
|
||||
'catastrophic_misses': [],
|
||||
'sample_size': 0,
|
||||
'minimum_required': 200,
|
||||
'note': 'Insufficient data for blind spot detection'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/clv/<sport>', methods=['GET'])
|
||||
def get_clv_report(sport):
|
||||
"""
|
||||
Get Closing Line Value report for a sport.
|
||||
CLV measures whether the market moved toward our position.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with CLV stats.
|
||||
"""
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'total_grades_with_clv': 0,
|
||||
'clv_win_rate': None,
|
||||
'avg_clv_magnitude': None,
|
||||
'note': 'CLV tracking begins when odds_warehouse has morning + pre-game data'
|
||||
})
|
||||
|
||||
|
||||
@calibration_bp.route('/alignment/<sport>', methods=['GET'])
|
||||
def get_alignment_report(sport):
|
||||
"""
|
||||
Get model-market alignment stats.
|
||||
Shows how often the market moves WITH vs AGAINST VYNDR's position.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with alignment stats.
|
||||
"""
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'confirming_count': 0,
|
||||
'contrarian_count': 0,
|
||||
'alignment_rate': None,
|
||||
'note': 'Alignment tracking begins with odds_warehouse data'
|
||||
})
|
||||
@@ -0,0 +1,938 @@
|
||||
"""
|
||||
VYNDR Coaching Tendency Database — tactical fingerprinting for every coach.
|
||||
Blueprint tracks coaching decisions game-over-game, detects mid-season
|
||||
philosophy shifts, and feeds tendency data into prop grading models.
|
||||
Supports both NBA and MLB with sport-specific field sets.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import Counter
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.retry import api_call_with_retry
|
||||
from utils.supabase_client import get_supabase_client
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
coaching_bp = Blueprint('coaching', __name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field definitions per sport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COACHING_FIELDS = {
|
||||
'nba': {
|
||||
'pace_preference': {
|
||||
'type': 'float',
|
||||
'description': 'Possessions per 48 minutes — fast (100+) vs grind-it-out (<95)',
|
||||
},
|
||||
'three_point_rate': {
|
||||
'type': 'float',
|
||||
'description': 'Fraction of field goal attempts from three-point range',
|
||||
},
|
||||
'isolation_frequency': {
|
||||
'type': 'float',
|
||||
'description': 'Percentage of possessions ending in isolation plays',
|
||||
},
|
||||
'pick_roll_usage': {
|
||||
'type': 'float',
|
||||
'description': 'Percentage of possessions using pick-and-roll actions',
|
||||
},
|
||||
'bench_rotation_depth': {
|
||||
'type': 'int',
|
||||
'description': 'Number of players receiving 10+ minutes per game',
|
||||
},
|
||||
'fouling_philosophy_late': {
|
||||
'type': 'str',
|
||||
'description': 'Late-game fouling tendency: aggressive, selective, passive',
|
||||
},
|
||||
'score_state_rotations': {
|
||||
'type': 'dict',
|
||||
'description': 'Lineup groups by score differential bucket (blowout/close/trailing)',
|
||||
},
|
||||
'late_game_possession_player': {
|
||||
'type': 'str',
|
||||
'description': 'Player who most often gets the ball in crunch time (last 2 min, within 5 pts)',
|
||||
},
|
||||
'second_unit_usage_pattern': {
|
||||
'type': 'str',
|
||||
'description': 'When and how the second unit is deployed — stagger vs full-bench',
|
||||
},
|
||||
'usage_redistribution_profile': {
|
||||
'type': 'dict',
|
||||
'description': 'How usage shifts when a starter sits — who absorbs touches',
|
||||
},
|
||||
'shot_location_allowances': {
|
||||
'type': 'dict',
|
||||
'description': 'Defensive scheme — rim protection vs perimeter switching emphasis',
|
||||
},
|
||||
'timeout_tendency': {
|
||||
'type': 'str',
|
||||
'description': 'Timeout calling pattern — early to stop runs, or ride momentum',
|
||||
},
|
||||
},
|
||||
'mlb': {
|
||||
'starter_hook_tendency': {
|
||||
'type': 'float',
|
||||
'description': 'Average innings before pulling the starter',
|
||||
},
|
||||
'quick_hook_threshold': {
|
||||
'type': 'float',
|
||||
'description': 'ERA / pitch-count threshold that triggers early pull',
|
||||
},
|
||||
'bullpen_usage_philosophy': {
|
||||
'type': 'str',
|
||||
'description': 'Matchup-based, innings-based, or closer-only mentality',
|
||||
},
|
||||
'intentional_walk_rate': {
|
||||
'type': 'float',
|
||||
'description': 'Intentional walks per 9 innings managed',
|
||||
},
|
||||
'pinch_hit_frequency': {
|
||||
'type': 'float',
|
||||
'description': 'Pinch-hit substitutions per game average',
|
||||
},
|
||||
'bunt_tendency': {
|
||||
'type': 'float',
|
||||
'description': 'Sacrifice bunts per game average',
|
||||
},
|
||||
'save_situation_closer_only': {
|
||||
'type': 'bool',
|
||||
'description': 'Whether manager uses closer exclusively in save situations',
|
||||
},
|
||||
'platoon_tendency': {
|
||||
'type': 'float',
|
||||
'description': 'Rate of platoon-advantaged lineup construction',
|
||||
},
|
||||
'lineup_consistency': {
|
||||
'type': 'float',
|
||||
'description': 'Percentage of games with identical top-6 batting order',
|
||||
},
|
||||
'challenge_aggressiveness': {
|
||||
'type': 'float',
|
||||
'description': 'Replay challenges per game average',
|
||||
},
|
||||
'high_leverage_hook_tendency': {
|
||||
'type': 'float',
|
||||
'description': 'How quickly manager pulls starter with runners on. '
|
||||
'Low = lets starter work through trouble (higher K ceiling). '
|
||||
'High = quick hook (reduced K ceiling, more bullpen exposure).',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@coaching_bp.route('/tendencies/<coach_id>', methods=['GET'])
|
||||
def get_coaching_tendencies(coach_id):
|
||||
"""
|
||||
Fetch coaching tendencies for a specific coach.
|
||||
|
||||
Query params:
|
||||
sport (str): 'nba' or 'mlb'. Required.
|
||||
|
||||
Returns:
|
||||
JSON with coach_id, sport, tendencies dict, and updated_at timestamp.
|
||||
"""
|
||||
sport = request.args.get('sport', '').lower()
|
||||
if sport not in ('nba', 'mlb'):
|
||||
return jsonify({'error': 'sport query param required — nba or mlb'}), 400
|
||||
|
||||
cache_key = f'coaching_tendencies:{sport}:{coach_id}'
|
||||
|
||||
def _fetch_tendencies():
|
||||
"""Pull coaching tendencies from Supabase."""
|
||||
client = get_supabase_client()
|
||||
if not client:
|
||||
logger.warning('[Coaching] Supabase client unavailable')
|
||||
return None
|
||||
try:
|
||||
resp = (
|
||||
client.table('coaching_tendencies')
|
||||
.select('*')
|
||||
.eq('coach_id', coach_id)
|
||||
.eq('sport', sport)
|
||||
.order('updated_at', desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if resp.data:
|
||||
return resp.data[0]
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error(f'[Coaching] Failed to fetch tendencies for {coach_id}: {exc}')
|
||||
return None
|
||||
|
||||
data = fetch_with_cache(cache_key, _fetch_tendencies, data_type='player_stats')
|
||||
if not data:
|
||||
return jsonify({'error': 'No coaching tendencies found', 'coach_id': coach_id}), 404
|
||||
|
||||
return jsonify({
|
||||
'coach_id': coach_id,
|
||||
'sport': sport,
|
||||
'tendencies': data.get('tendencies', {}),
|
||||
'updated_at': data.get('updated_at'),
|
||||
})
|
||||
|
||||
|
||||
@coaching_bp.route('/shift-detection/<team_id>', methods=['GET'])
|
||||
def detect_coaching_shifts(team_id):
|
||||
"""
|
||||
Compare the last 15 games to the season baseline and flag any field
|
||||
where the recent value deviates by 15 %+ from the baseline.
|
||||
|
||||
Query params:
|
||||
sport (str): 'nba' or 'mlb'. Required.
|
||||
|
||||
Returns:
|
||||
JSON with team_id, sport, and a list of detected shifts. Each shift
|
||||
contains field, baseline, recent, change_pct, and direction.
|
||||
"""
|
||||
sport = request.args.get('sport', '').lower()
|
||||
if sport not in ('nba', 'mlb'):
|
||||
return jsonify({'error': 'sport query param required — nba or mlb'}), 400
|
||||
|
||||
baseline = get_season_baseline(team_id, sport)
|
||||
recent = get_recent_tendencies(team_id, sport, window=15)
|
||||
|
||||
if not baseline or not recent:
|
||||
return jsonify({
|
||||
'error': 'Insufficient data for shift detection',
|
||||
'team_id': team_id,
|
||||
}), 404
|
||||
|
||||
shifts = []
|
||||
numeric_fields = [
|
||||
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
|
||||
if meta['type'] in ('float', 'int')
|
||||
]
|
||||
|
||||
for field in numeric_fields:
|
||||
base_val = baseline.get(field)
|
||||
recent_val = recent.get(field)
|
||||
if base_val is None or recent_val is None:
|
||||
continue
|
||||
try:
|
||||
base_val = float(base_val)
|
||||
recent_val = float(recent_val)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if base_val == 0:
|
||||
continue
|
||||
|
||||
change_pct = abs(recent_val - base_val) / abs(base_val) * 100
|
||||
if change_pct >= 15.0:
|
||||
direction = 'up' if recent_val > base_val else 'down'
|
||||
shifts.append({
|
||||
'field': field,
|
||||
'baseline': round(base_val, 4),
|
||||
'recent': round(recent_val, 4),
|
||||
'change_pct': round(change_pct, 2),
|
||||
'direction': direction,
|
||||
})
|
||||
|
||||
shifts.sort(key=lambda s: s['change_pct'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'team_id': team_id,
|
||||
'sport': sport,
|
||||
'window': 15,
|
||||
'threshold_pct': 15.0,
|
||||
'shifts': shifts,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Season baseline & recent tendencies helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_season_baseline(team_id, sport):
|
||||
"""
|
||||
Retrieve the full-season average coaching tendencies for a team.
|
||||
|
||||
Args:
|
||||
team_id: Team identifier string.
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
Dict of field -> averaged value across all games this season,
|
||||
or None if data unavailable.
|
||||
"""
|
||||
client = get_supabase_client()
|
||||
if not client:
|
||||
return None
|
||||
try:
|
||||
resp = (
|
||||
client.table('coaching_tendencies')
|
||||
.select('tendencies')
|
||||
.eq('team_id', team_id)
|
||||
.eq('sport', sport)
|
||||
.execute()
|
||||
)
|
||||
if not resp.data:
|
||||
return None
|
||||
return _average_tendency_rows(resp.data, sport)
|
||||
except Exception as exc:
|
||||
logger.error(f'[Coaching] Season baseline fetch failed for {team_id}: {exc}')
|
||||
return None
|
||||
|
||||
|
||||
def get_recent_tendencies(team_id, sport, window=15):
|
||||
"""
|
||||
Retrieve coaching tendencies from the most recent N games.
|
||||
|
||||
Args:
|
||||
team_id: Team identifier string.
|
||||
sport: 'nba' or 'mlb'.
|
||||
window: Number of recent games to include.
|
||||
|
||||
Returns:
|
||||
Dict of field -> averaged value across the window,
|
||||
or None if data unavailable.
|
||||
"""
|
||||
client = get_supabase_client()
|
||||
if not client:
|
||||
return None
|
||||
try:
|
||||
resp = (
|
||||
client.table('coaching_tendencies')
|
||||
.select('tendencies')
|
||||
.eq('team_id', team_id)
|
||||
.eq('sport', sport)
|
||||
.order('game_date', desc=True)
|
||||
.limit(window)
|
||||
.execute()
|
||||
)
|
||||
if not resp.data:
|
||||
return None
|
||||
return _average_tendency_rows(resp.data, sport)
|
||||
except Exception as exc:
|
||||
logger.error(f'[Coaching] Recent tendencies fetch failed for {team_id}: {exc}')
|
||||
return None
|
||||
|
||||
|
||||
def _average_tendency_rows(rows, sport):
|
||||
"""
|
||||
Average numeric tendency fields across multiple game rows.
|
||||
|
||||
Args:
|
||||
rows: List of dicts, each containing a 'tendencies' dict.
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
Dict of field -> averaged numeric value. Non-numeric fields use
|
||||
the most recent value.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
numeric_fields = [
|
||||
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
|
||||
if meta['type'] in ('float', 'int')
|
||||
]
|
||||
|
||||
sums = {f: 0.0 for f in numeric_fields}
|
||||
counts = {f: 0 for f in numeric_fields}
|
||||
result = {}
|
||||
|
||||
for row in rows:
|
||||
tendencies = row.get('tendencies', {})
|
||||
if not tendencies:
|
||||
continue
|
||||
for field in numeric_fields:
|
||||
val = tendencies.get(field)
|
||||
if val is not None:
|
||||
try:
|
||||
sums[field] += float(val)
|
||||
counts[field] += 1
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
for field in numeric_fields:
|
||||
if counts[field] > 0:
|
||||
result[field] = round(sums[field] / counts[field], 4)
|
||||
|
||||
# For non-numeric fields, take the most recent value
|
||||
most_recent = rows[0].get('tendencies', {}) if rows else {}
|
||||
non_numeric = [
|
||||
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
|
||||
if meta['type'] not in ('float', 'int')
|
||||
]
|
||||
for field in non_numeric:
|
||||
val = most_recent.get(field)
|
||||
if val is not None:
|
||||
result[field] = val
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nightly update pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_coaching_tendencies(game_date):
|
||||
"""
|
||||
Nightly job: iterate all completed games for the given date,
|
||||
parse coaching decisions from both sides, and upsert to Supabase.
|
||||
|
||||
Args:
|
||||
game_date: date object or ISO string (YYYY-MM-DD) for the target day.
|
||||
"""
|
||||
if isinstance(game_date, str):
|
||||
game_date = datetime.strptime(game_date, '%Y-%m-%d').date()
|
||||
|
||||
logger.info(f'[Coaching] Running nightly update for {game_date.isoformat()}')
|
||||
|
||||
# Fetch completed games for the date
|
||||
nba_games = _fetch_completed_games(game_date, 'nba')
|
||||
mlb_games = _fetch_completed_games(game_date, 'mlb')
|
||||
|
||||
processed = 0
|
||||
|
||||
for game in nba_games:
|
||||
for side in ('home', 'away'):
|
||||
try:
|
||||
tendencies = parse_nba_coaching_decisions(game, side)
|
||||
if tendencies:
|
||||
upsert_coaching_tendencies(
|
||||
coach_id=tendencies.pop('coach_id', None),
|
||||
team_id=tendencies.pop('team_id', None),
|
||||
sport='nba',
|
||||
game_id=game.get('game_id'),
|
||||
game_date=game_date,
|
||||
tendencies=tendencies,
|
||||
)
|
||||
processed += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f'[Coaching] NBA parse failed game={game.get("game_id")} '
|
||||
f'side={side}: {exc}'
|
||||
)
|
||||
|
||||
for game in mlb_games:
|
||||
for side in ('home', 'away'):
|
||||
try:
|
||||
tendencies = parse_mlb_coaching_decisions(game, side)
|
||||
if tendencies:
|
||||
upsert_coaching_tendencies(
|
||||
coach_id=tendencies.pop('coach_id', None),
|
||||
team_id=tendencies.pop('team_id', None),
|
||||
sport='mlb',
|
||||
game_id=game.get('game_id'),
|
||||
game_date=game_date,
|
||||
tendencies=tendencies,
|
||||
)
|
||||
processed += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f'[Coaching] MLB parse failed game={game.get("game_id")} '
|
||||
f'side={side}: {exc}'
|
||||
)
|
||||
|
||||
logger.info(f'[Coaching] Nightly update complete — {processed} entries upserted')
|
||||
return processed
|
||||
|
||||
|
||||
def _fetch_completed_games(game_date, sport):
|
||||
"""
|
||||
Retrieve completed games for a given date and sport from the data warehouse.
|
||||
|
||||
Args:
|
||||
game_date: date object.
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
List of game dicts with box score / play-by-play data attached.
|
||||
"""
|
||||
cache_key = f'completed_games:{sport}:{game_date.isoformat()}'
|
||||
|
||||
def _fetch():
|
||||
client = get_supabase_client()
|
||||
if not client:
|
||||
return []
|
||||
try:
|
||||
resp = (
|
||||
client.table('games')
|
||||
.select('*')
|
||||
.eq('sport', sport)
|
||||
.eq('game_date', game_date.isoformat())
|
||||
.eq('status', 'completed')
|
||||
.execute()
|
||||
)
|
||||
return resp.data or []
|
||||
except Exception as exc:
|
||||
logger.error(f'[Coaching] Game fetch failed for {sport} {game_date}: {exc}')
|
||||
return []
|
||||
|
||||
return fetch_with_cache(cache_key, _fetch, data_type='player_stats') or []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NBA coaching decision parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_nba_coaching_decisions(game, side):
|
||||
"""
|
||||
Extract coaching tendency signals from an NBA game's box score
|
||||
and play-by-play data for one side (home or away).
|
||||
|
||||
Args:
|
||||
game: Game dict with nested box score and play-by-play.
|
||||
side: 'home' or 'away'.
|
||||
|
||||
Returns:
|
||||
Dict of coaching tendency fields, or None if data insufficient.
|
||||
"""
|
||||
box = game.get(f'{side}_box', {})
|
||||
pbp = game.get('play_by_play', [])
|
||||
team_id = game.get(f'{side}_team_id')
|
||||
coach_id = game.get(f'{side}_coach_id')
|
||||
|
||||
if not box or not team_id:
|
||||
return None
|
||||
|
||||
players = box.get('players', [])
|
||||
if not players:
|
||||
return None
|
||||
|
||||
# Rotation depth: players with 10+ minutes
|
||||
rotation_depth = sum(1 for p in players if (p.get('minutes', 0) or 0) >= 10)
|
||||
|
||||
# Late game possession player (last 2 min, within 5 pts)
|
||||
late_game_player = _find_late_game_possession_player(pbp, team_id)
|
||||
|
||||
# Pace: possessions per 48 from box score
|
||||
pace = box.get('pace', None)
|
||||
|
||||
# Three-point rate
|
||||
three_rate = calculate_three_rate(players)
|
||||
|
||||
# Score-state lineups
|
||||
score_state = extract_score_state_lineups(pbp, team_id)
|
||||
|
||||
tendencies = {
|
||||
'coach_id': coach_id,
|
||||
'team_id': team_id,
|
||||
'bench_rotation_depth': rotation_depth,
|
||||
'late_game_possession_player': late_game_player,
|
||||
'pace_preference': pace,
|
||||
'three_point_rate': three_rate,
|
||||
'score_state_rotations': score_state,
|
||||
}
|
||||
|
||||
# Additional fields parsed from play-by-play when available
|
||||
iso_freq = box.get('isolation_frequency')
|
||||
if iso_freq is not None:
|
||||
tendencies['isolation_frequency'] = iso_freq
|
||||
|
||||
pr_usage = box.get('pick_roll_usage')
|
||||
if pr_usage is not None:
|
||||
tendencies['pick_roll_usage'] = pr_usage
|
||||
|
||||
return tendencies
|
||||
|
||||
|
||||
def _find_late_game_possession_player(pbp, team_id):
|
||||
"""
|
||||
Identify the player who most frequently has the ball in crunch time
|
||||
(last 2 minutes of 4th quarter / OT, score within 5 points).
|
||||
|
||||
Args:
|
||||
pbp: List of play-by-play event dicts.
|
||||
team_id: Team identifier to filter possessions.
|
||||
|
||||
Returns:
|
||||
Player name string or None.
|
||||
"""
|
||||
crunch_possessions = []
|
||||
for event in pbp:
|
||||
period = event.get('period', 0)
|
||||
clock = event.get('clock', '')
|
||||
margin = abs(event.get('score_margin', 999))
|
||||
event_team = event.get('team_id')
|
||||
|
||||
if event_team != team_id:
|
||||
continue
|
||||
if period < 4:
|
||||
continue
|
||||
if margin > 5:
|
||||
continue
|
||||
|
||||
# Parse clock — expect "MM:SS" or seconds remaining
|
||||
remaining = _parse_clock(clock)
|
||||
if remaining is not None and remaining <= 120:
|
||||
player = event.get('player_name') or event.get('player_id')
|
||||
if player:
|
||||
crunch_possessions.append(player)
|
||||
|
||||
return most_common_player(crunch_possessions)
|
||||
|
||||
|
||||
def _parse_clock(clock):
|
||||
"""
|
||||
Parse game clock string into seconds remaining.
|
||||
|
||||
Args:
|
||||
clock: String like '1:45' or numeric seconds.
|
||||
|
||||
Returns:
|
||||
Float seconds remaining, or None if unparseable.
|
||||
"""
|
||||
if clock is None:
|
||||
return None
|
||||
if isinstance(clock, (int, float)):
|
||||
return float(clock)
|
||||
try:
|
||||
parts = str(clock).split(':')
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]) * 60 + float(parts[1])
|
||||
return float(clock)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MLB coaching decision parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_mlb_coaching_decisions(game, side):
|
||||
"""
|
||||
Extract coaching tendency signals from an MLB game for one side.
|
||||
|
||||
Args:
|
||||
game: Game dict with box score and play-by-play data.
|
||||
side: 'home' or 'away'.
|
||||
|
||||
Returns:
|
||||
Dict of coaching tendency fields, or None if data insufficient.
|
||||
"""
|
||||
box = game.get(f'{side}_box', {})
|
||||
pbp = game.get('play_by_play', [])
|
||||
team_id = game.get(f'{side}_team_id')
|
||||
coach_id = game.get(f'{side}_coach_id')
|
||||
|
||||
if not box or not team_id:
|
||||
return None
|
||||
|
||||
pitching = box.get('pitching', {})
|
||||
batting = box.get('batting', {})
|
||||
|
||||
# Starter hook tendency — innings pitched by the starter
|
||||
starter = pitching.get('starter', {})
|
||||
starter_ip = starter.get('innings_pitched', None)
|
||||
|
||||
# Pinch-hit frequency
|
||||
pinch_hits = count_pinch_hits(pbp, team_id)
|
||||
|
||||
# Bunt tendency
|
||||
sac_bunts = count_sacrifice_bunts(pbp, team_id)
|
||||
|
||||
# Challenge aggressiveness
|
||||
challenges = box.get('challenges_used', 0) or 0
|
||||
|
||||
tendencies = {
|
||||
'coach_id': coach_id,
|
||||
'team_id': team_id,
|
||||
'starter_hook_tendency': float(starter_ip) if starter_ip is not None else None,
|
||||
'pinch_hit_frequency': pinch_hits,
|
||||
'bunt_tendency': sac_bunts,
|
||||
'challenge_aggressiveness': challenges,
|
||||
}
|
||||
|
||||
# Intentional walks from pitching data
|
||||
ibb = pitching.get('intentional_walks', None)
|
||||
if ibb is not None:
|
||||
tendencies['intentional_walk_rate'] = float(ibb)
|
||||
|
||||
return tendencies
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def most_common_player(player_list):
|
||||
"""
|
||||
Return the most frequently occurring player name from a list.
|
||||
|
||||
Args:
|
||||
player_list: List of player name strings.
|
||||
|
||||
Returns:
|
||||
Most common player name, or None if list is empty.
|
||||
"""
|
||||
if not player_list:
|
||||
return None
|
||||
counter = Counter(player_list)
|
||||
return counter.most_common(1)[0][0]
|
||||
|
||||
|
||||
def extract_score_state_lineups(pbp, team_id):
|
||||
"""
|
||||
Group on-court lineups by score-state buckets for a given team.
|
||||
|
||||
Score-state buckets:
|
||||
- blowout_ahead: team leading by 15+
|
||||
- comfortable: team leading by 6-14
|
||||
- close: margin within 5
|
||||
- trailing: team down by 6-14
|
||||
- blowout_behind: team down by 15+
|
||||
|
||||
Args:
|
||||
pbp: Play-by-play event list.
|
||||
team_id: Team identifier.
|
||||
|
||||
Returns:
|
||||
Dict mapping bucket name to the most common lineup (list of player names)
|
||||
seen in that bucket, or empty dict if no data.
|
||||
"""
|
||||
buckets = {
|
||||
'blowout_ahead': [],
|
||||
'comfortable': [],
|
||||
'close': [],
|
||||
'trailing': [],
|
||||
'blowout_behind': [],
|
||||
}
|
||||
|
||||
for event in pbp:
|
||||
if event.get('team_id') != team_id:
|
||||
continue
|
||||
lineup = event.get('lineup', [])
|
||||
if not lineup:
|
||||
continue
|
||||
|
||||
margin = event.get('score_margin', 0) or 0
|
||||
lineup_key = tuple(sorted(lineup))
|
||||
|
||||
if margin >= 15:
|
||||
buckets['blowout_ahead'].append(lineup_key)
|
||||
elif margin >= 6:
|
||||
buckets['comfortable'].append(lineup_key)
|
||||
elif margin >= -5:
|
||||
buckets['close'].append(lineup_key)
|
||||
elif margin >= -14:
|
||||
buckets['trailing'].append(lineup_key)
|
||||
else:
|
||||
buckets['blowout_behind'].append(lineup_key)
|
||||
|
||||
result = {}
|
||||
for bucket, lineups in buckets.items():
|
||||
if lineups:
|
||||
counter = Counter(lineups)
|
||||
most_common = counter.most_common(1)[0][0]
|
||||
result[bucket] = list(most_common)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def calculate_three_rate(players):
|
||||
"""
|
||||
Calculate three-point attempt rate from player box score data.
|
||||
|
||||
Args:
|
||||
players: List of player box score dicts with 'fga' and 'fg3a' fields.
|
||||
|
||||
Returns:
|
||||
Float three-point rate (0.0-1.0), or None if no FGA data.
|
||||
"""
|
||||
total_fga = 0
|
||||
total_fg3a = 0
|
||||
for p in players:
|
||||
fga = p.get('fga', 0) or 0
|
||||
fg3a = p.get('fg3a', 0) or 0
|
||||
total_fga += fga
|
||||
total_fg3a += fg3a
|
||||
|
||||
if total_fga == 0:
|
||||
return None
|
||||
return round(total_fg3a / total_fga, 4)
|
||||
|
||||
|
||||
def count_pinch_hits(pbp, team_id):
|
||||
"""
|
||||
Count pinch-hit substitutions for a team from play-by-play data.
|
||||
|
||||
Args:
|
||||
pbp: Play-by-play event list.
|
||||
team_id: Team identifier.
|
||||
|
||||
Returns:
|
||||
Integer count of pinch-hit appearances.
|
||||
"""
|
||||
count = 0
|
||||
for event in pbp:
|
||||
if event.get('team_id') != team_id:
|
||||
continue
|
||||
event_type = (event.get('event_type') or '').lower()
|
||||
description = (event.get('description') or '').lower()
|
||||
if 'pinch' in event_type or 'pinch hit' in description:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def count_sacrifice_bunts(pbp, team_id):
|
||||
"""
|
||||
Count sacrifice bunt attempts for a team from play-by-play data.
|
||||
|
||||
Args:
|
||||
pbp: Play-by-play event list.
|
||||
team_id: Team identifier.
|
||||
|
||||
Returns:
|
||||
Integer count of sacrifice bunts.
|
||||
"""
|
||||
count = 0
|
||||
for event in pbp:
|
||||
if event.get('team_id') != team_id:
|
||||
continue
|
||||
event_type = (event.get('event_type') or '').lower()
|
||||
description = (event.get('description') or '').lower()
|
||||
if 'sacrifice' in event_type and 'bunt' in event_type:
|
||||
count += 1
|
||||
elif 'sac bunt' in description or 'sacrifice bunt' in description:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upsert_coaching_tendencies(coach_id, team_id, sport, game_id, game_date, tendencies):
|
||||
"""
|
||||
Upsert coaching tendency data into the Supabase coaching_tendencies table.
|
||||
|
||||
Uses (coach_id, sport, game_id) as the conflict key so re-processing a
|
||||
date is idempotent.
|
||||
|
||||
Args:
|
||||
coach_id: Coach identifier string.
|
||||
team_id: Team identifier string.
|
||||
sport: 'nba' or 'mlb'.
|
||||
game_id: Unique game identifier.
|
||||
game_date: date object for the game.
|
||||
tendencies: Dict of tendency field -> value.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
client = get_supabase_client()
|
||||
if not client:
|
||||
logger.warning('[Coaching] Cannot upsert — Supabase client unavailable')
|
||||
return False
|
||||
|
||||
if isinstance(game_date, date):
|
||||
game_date_str = game_date.isoformat()
|
||||
else:
|
||||
game_date_str = str(game_date)
|
||||
|
||||
row = {
|
||||
'coach_id': coach_id,
|
||||
'team_id': team_id,
|
||||
'sport': sport,
|
||||
'game_id': game_id,
|
||||
'game_date': game_date_str,
|
||||
'tendencies': tendencies,
|
||||
'updated_at': datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
client.table('coaching_tendencies').upsert(
|
||||
row, on_conflict='coach_id,sport,game_id'
|
||||
).execute()
|
||||
logger.info(
|
||||
f'[Coaching] Upserted tendencies coach={coach_id} game={game_id}'
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f'[Coaching] Upsert failed coach={coach_id} game={game_id}: {exc}'
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH Item 15: Historical seeding wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_nba_coaching_from_game_id(game_id):
|
||||
"""
|
||||
Wrapper for historical seeding — fetches NBA game data then parses.
|
||||
Called by scripts/seed_historical.py.
|
||||
|
||||
Args:
|
||||
game_id: NBA game ID string.
|
||||
"""
|
||||
import time
|
||||
time.sleep(0.6)
|
||||
try:
|
||||
from nba_api.stats.endpoints import BoxScoreTraditionalV2, PlayByPlayV2
|
||||
box = BoxScoreTraditionalV2(game_id=game_id)
|
||||
time.sleep(0.6)
|
||||
pbp = PlayByPlayV2(game_id=game_id)
|
||||
|
||||
box_dfs = box.get_data_frames()
|
||||
pbp_df = pbp.get_data_frames()[0]
|
||||
|
||||
game_data = {
|
||||
'boxscore': _format_box_for_coaching(box_dfs),
|
||||
'play_by_play': _format_pbp_for_coaching(pbp_df),
|
||||
'game_date': None
|
||||
}
|
||||
|
||||
for side in ['home', 'away']:
|
||||
tendencies = parse_nba_coaching_decisions(game_data, side)
|
||||
coach_id = game_data.get(f'{side}_coach_id', f'unknown_{side}')
|
||||
team_id = game_data.get(f'{side}_team_id', f'unknown_{side}')
|
||||
upsert_coaching_tendencies(
|
||||
coach_id, team_id, 'nba', tendencies,
|
||||
game_data.get('game_date'), game_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'[Coaching] NBA historical parse failed for {game_id}: {e}')
|
||||
|
||||
|
||||
def parse_mlb_coaching_from_game_id(game_id):
|
||||
"""
|
||||
Wrapper for historical seeding — fetches MLB game data then parses.
|
||||
Called by scripts/seed_historical.py.
|
||||
|
||||
Args:
|
||||
game_id: MLB game ID (gamePk).
|
||||
"""
|
||||
try:
|
||||
import statsapi
|
||||
game_data = statsapi.get('game', {'gamePk': game_id})
|
||||
|
||||
for side in ['home', 'away']:
|
||||
tendencies = parse_mlb_coaching_decisions(game_data, side)
|
||||
team_data = game_data.get('gameData', {}).get('teams', {}).get(side, {})
|
||||
coach_id = str(team_data.get('id', f'unknown_{side}'))
|
||||
team_id = str(team_data.get('id', f'unknown_{side}'))
|
||||
game_date = game_data.get('gameData', {}).get('datetime', {}).get('officialDate')
|
||||
upsert_coaching_tendencies(
|
||||
coach_id, team_id, 'mlb', tendencies, game_date, str(game_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'[Coaching] MLB historical parse failed for {game_id}: {e}')
|
||||
|
||||
|
||||
def _format_box_for_coaching(box_dfs):
|
||||
"""Format BoxScoreTraditionalV2 DataFrames for coaching parser."""
|
||||
return {}
|
||||
|
||||
|
||||
def _format_pbp_for_coaching(pbp_df):
|
||||
"""Format PlayByPlayV2 DataFrame for coaching parser."""
|
||||
return []
|
||||
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
VYNDR Evolution Engine — Blueprint
|
||||
PELT changepoint detection for player metric evolution.
|
||||
Structural migration from evolutionEngine.py — logic unchanged.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import numpy as np
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
evolution_bp = Blueprint('evolution', __name__)
|
||||
|
||||
# Graceful import — ruptures may not be installed
|
||||
try:
|
||||
import ruptures as rpt
|
||||
HAS_RUPTURES = True
|
||||
except ImportError:
|
||||
HAS_RUPTURES = False
|
||||
print("[evolution-engine] WARNING: ruptures not installed. Using fallback.", file=sys.stderr)
|
||||
|
||||
|
||||
def detect_changepoints_pelt(values, min_size=5, penalty=3.0):
|
||||
"""
|
||||
Use PELT algorithm from ruptures library.
|
||||
Detects changepoints in time-series data for player metric evolution.
|
||||
|
||||
Args:
|
||||
values: List of numeric values (e.g., game-by-game stat line).
|
||||
min_size: Minimum segment length between changepoints.
|
||||
penalty: PELT penalty parameter — higher = fewer changepoints.
|
||||
|
||||
Returns:
|
||||
Dict with changepoints list, confidence scores, and algorithm used.
|
||||
"""
|
||||
if not HAS_RUPTURES:
|
||||
return fallback_detect(values)
|
||||
|
||||
signal = np.array(values, dtype=float)
|
||||
if len(signal) < min_size * 2:
|
||||
return {"changepoints": [], "confidence": [], "algorithm": "PELT"}
|
||||
|
||||
algo = rpt.Pelt(model="rbf", min_size=min_size).fit(signal)
|
||||
result = algo.predict(pen=penalty)
|
||||
|
||||
# Remove the last element (always = len(signal))
|
||||
changepoints = [cp for cp in result if cp < len(signal)]
|
||||
|
||||
# Calculate confidence for each changepoint
|
||||
confidences = []
|
||||
for cp in changepoints:
|
||||
left = signal[max(0, cp - min_size):cp]
|
||||
right = signal[cp:min(len(signal), cp + min_size)]
|
||||
if len(left) > 0 and len(right) > 0:
|
||||
diff = abs(np.mean(right) - np.mean(left))
|
||||
std = max(np.std(signal), 0.01)
|
||||
conf = min(diff / std, 1.0)
|
||||
confidences.append(round(conf, 3))
|
||||
else:
|
||||
confidences.append(0.0)
|
||||
|
||||
return {
|
||||
"changepoints": changepoints,
|
||||
"confidence": confidences,
|
||||
"algorithm": "PELT",
|
||||
}
|
||||
|
||||
|
||||
def fallback_detect(values):
|
||||
"""Simple window-based fallback when ruptures unavailable."""
|
||||
if len(values) < 10:
|
||||
return {"changepoints": [], "confidence": [], "algorithm": "fallback"}
|
||||
|
||||
signal = np.array(values, dtype=float)
|
||||
window = max(5, len(signal) // 5)
|
||||
changepoints = []
|
||||
confidences = []
|
||||
|
||||
for i in range(window, len(signal) - window):
|
||||
left_mean = np.mean(signal[i - window:i])
|
||||
right_mean = np.mean(signal[i:i + window])
|
||||
std = max(np.std(signal), 0.01)
|
||||
diff = abs(right_mean - left_mean)
|
||||
if diff / std > 1.5:
|
||||
changepoints.append(i)
|
||||
confidences.append(min(round(diff / std / 3.0, 3), 1.0))
|
||||
|
||||
# Deduplicate nearby changepoints
|
||||
filtered_cp = []
|
||||
filtered_conf = []
|
||||
for cp, conf in zip(changepoints, confidences):
|
||||
if not filtered_cp or cp - filtered_cp[-1] >= window:
|
||||
filtered_cp.append(cp)
|
||||
filtered_conf.append(conf)
|
||||
|
||||
return {
|
||||
"changepoints": filtered_cp,
|
||||
"confidence": filtered_conf,
|
||||
"algorithm": "fallback",
|
||||
}
|
||||
|
||||
|
||||
@evolution_bp.route("/health", methods=["GET"])
|
||||
def evolution_health():
|
||||
"""Health check for evolution engine subsystem."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"ruptures_available": HAS_RUPTURES,
|
||||
})
|
||||
|
||||
|
||||
@evolution_bp.route("/detect-changepoints", methods=["POST"])
|
||||
def detect_changepoints():
|
||||
"""
|
||||
Detect changepoints in a time-series of player metrics.
|
||||
|
||||
Request body:
|
||||
values: List[float] — metric values in chronological order.
|
||||
min_size: int (optional, default 5) — minimum segment length.
|
||||
penalty: float (optional, default 3.0) — PELT penalty.
|
||||
player_id: str (optional) — for logging.
|
||||
metric: str (optional) — metric name for logging.
|
||||
|
||||
Returns:
|
||||
changepoints: List[int] — indices where regime changes detected.
|
||||
confidence: List[float] — confidence score per changepoint.
|
||||
algorithm: str — 'PELT' or 'fallback'.
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
values = data.get("values", [])
|
||||
if not values or len(values) < 5:
|
||||
return jsonify({
|
||||
"changepoints": [],
|
||||
"confidence": [],
|
||||
"algorithm": "PELT",
|
||||
"note": "Insufficient data points",
|
||||
})
|
||||
|
||||
result = detect_changepoints_pelt(
|
||||
values,
|
||||
min_size=data.get("min_size", 5),
|
||||
penalty=data.get("penalty", 3.0),
|
||||
)
|
||||
result["player_id"] = data.get("player_id")
|
||||
result["metric"] = data.get("metric")
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SUPPLEMENT: Player Evolution Alerting
|
||||
# ============================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# Metrics to scan per sport
|
||||
EVOLUTION_METRICS = {
|
||||
'nba': ['usage_rate', 'assist_rate', 'three_pa_rate', 'fg_pct', 'minutes'],
|
||||
'mlb': ['k_rate', 'bb_rate', 'exit_velocity', 'hard_hit_pct', 'fb_velo']
|
||||
}
|
||||
|
||||
EVOLUTION_MIN_GAMES = 15
|
||||
EVOLUTION_CHANGE_THRESHOLD = 0.10 # 10% change
|
||||
EVOLUTION_MIN_CONCURRENT = 2 # 2+ metrics must inflect
|
||||
|
||||
|
||||
def detect_player_evolution(player_id, sport, metric_data=None):
|
||||
"""
|
||||
Use PELT to detect inflection points across multiple metrics simultaneously.
|
||||
Flag PLAYER_EVOLUTION_DETECTED when 2+ metrics show concurrent inflection
|
||||
(10%+ change in last 5 games vs prior window, minimum 15 games total).
|
||||
|
||||
Args:
|
||||
player_id: Player identifier.
|
||||
sport: 'nba' or 'mlb'.
|
||||
metric_data: Optional dict mapping metric name to list of values.
|
||||
If None, would be fetched from data warehouse in production.
|
||||
|
||||
Returns:
|
||||
Dict with evolution_detected (bool) and inflection details if detected.
|
||||
"""
|
||||
metrics = EVOLUTION_METRICS.get(sport, [])
|
||||
inflections = {}
|
||||
|
||||
for metric in metrics:
|
||||
if metric_data and metric in metric_data:
|
||||
values = metric_data[metric]
|
||||
else:
|
||||
values = _get_player_metric_series(player_id, metric)
|
||||
|
||||
if len(values) < EVOLUTION_MIN_GAMES:
|
||||
continue
|
||||
|
||||
result = detect_changepoints_pelt(values)
|
||||
changepoints = result.get('changepoints', [])
|
||||
|
||||
if changepoints:
|
||||
latest_cp = max(changepoints)
|
||||
# Inflection must be in last 5 games of the series
|
||||
if latest_cp >= len(values) - 5:
|
||||
before_vals = values[:latest_cp]
|
||||
after_vals = values[latest_cp:]
|
||||
if before_vals and after_vals:
|
||||
before_mean = float(np.mean(before_vals))
|
||||
after_mean = float(np.mean(after_vals))
|
||||
denominator = max(abs(before_mean), 0.01)
|
||||
pct_change = (after_mean - before_mean) / denominator
|
||||
|
||||
if abs(pct_change) > EVOLUTION_CHANGE_THRESHOLD:
|
||||
inflections[metric] = {
|
||||
'before': round(before_mean, 3),
|
||||
'after': round(after_mean, 3),
|
||||
'change_pct': round(pct_change * 100, 1),
|
||||
'direction': 'ascending' if pct_change > 0 else 'descending',
|
||||
'changepoint_game': latest_cp
|
||||
}
|
||||
|
||||
if len(inflections) >= EVOLUTION_MIN_CONCURRENT:
|
||||
return {
|
||||
'evolution_detected': True,
|
||||
'player_id': player_id,
|
||||
'sport': sport,
|
||||
'detection_date': date.today().isoformat(),
|
||||
'metrics_inflecting': len(inflections),
|
||||
'inflections': inflections,
|
||||
}
|
||||
|
||||
return {'evolution_detected': False, 'player_id': player_id}
|
||||
|
||||
|
||||
def log_evolution_detection(evolution):
|
||||
"""
|
||||
Create timestamped, verifiable record in evolution_detections table.
|
||||
After one season: 'We detected X inflection points. Y confirmed by market movement.'
|
||||
|
||||
Args:
|
||||
evolution: Dict from detect_player_evolution with evolution_detected=True.
|
||||
"""
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if supabase:
|
||||
supabase.table('evolution_detections').insert({
|
||||
'player_id': evolution['player_id'],
|
||||
'player_name': evolution.get('player_name'),
|
||||
'sport': evolution['sport'],
|
||||
'detection_date': evolution['detection_date'],
|
||||
'metrics': json.dumps(evolution['inflections']),
|
||||
'market_adjusted_at': None,
|
||||
'confirmed': None
|
||||
}).execute()
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Evolution detection log failed: {e}')
|
||||
|
||||
|
||||
def format_evolution_watch_post(evolutions):
|
||||
"""
|
||||
Weekly content: 'VYNDR Evolution Watch'
|
||||
Players whose stats are inflecting before market adjustment.
|
||||
|
||||
Args:
|
||||
evolutions: List of evolution detection dicts.
|
||||
|
||||
Returns:
|
||||
Formatted post string, or None if no evolutions.
|
||||
"""
|
||||
if not evolutions:
|
||||
return None
|
||||
|
||||
lines = ["\U0001f52c VYNDR Evolution Watch\n"]
|
||||
lines.append("Players inflecting before the market catches up:\n")
|
||||
|
||||
for evo in evolutions[:5]:
|
||||
metrics = evo.get('inflections', {})
|
||||
if not metrics:
|
||||
continue
|
||||
top_metric = max(metrics.items(), key=lambda x: abs(x[1]['change_pct']))
|
||||
direction = '\U0001f4c8' if top_metric[1]['direction'] == 'ascending' else '\U0001f4c9'
|
||||
|
||||
lines.append(
|
||||
f"{direction} {evo.get('player_name', evo['player_id'])} \u2014 "
|
||||
f"{top_metric[0]}: {top_metric[1]['before']} \u2192 {top_metric[1]['after']} "
|
||||
f"({top_metric[1]['change_pct']:+.1f}%)"
|
||||
)
|
||||
|
||||
lines.append("\nThe model sees it. The market hasn't priced it yet.")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _get_player_metric_series(player_id, metric, n_games=30):
|
||||
"""Stub: fetch player metric time series from data warehouse."""
|
||||
return []
|
||||
|
||||
|
||||
@evolution_bp.route("/scan/<sport>", methods=["GET"])
|
||||
def scan_for_evolutions(sport):
|
||||
"""
|
||||
Scan all active players for evolution inflection points.
|
||||
Run daily. Creates timestamped records for accuracy ledger.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
JSON with scan results and detected evolutions.
|
||||
"""
|
||||
# In production, get_active_players fetches from Supabase
|
||||
evolutions = []
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'scan_date': date.today().isoformat(),
|
||||
'evolutions_detected': len(evolutions),
|
||||
'evolutions': evolutions,
|
||||
'note': 'Connect to player data source for live scanning'
|
||||
})
|
||||
|
||||
|
||||
@evolution_bp.route("/watch-post", methods=["GET"])
|
||||
def get_evolution_watch():
|
||||
"""Get formatted Evolution Watch post for capper account."""
|
||||
return jsonify({
|
||||
'post': None,
|
||||
'note': 'No evolutions detected yet'
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PATCH Item 8: Evolution Persistence Check
|
||||
# ============================================================
|
||||
|
||||
EVOLUTION_PERSISTENCE_GAMES = 3 # games before public promotion
|
||||
|
||||
|
||||
def promote_evolution_to_public(evolution_record, games_since_detection):
|
||||
"""
|
||||
Evolution detected internally on day X.
|
||||
Only promote to Evolution Watch content after 3 games of persistence.
|
||||
If inflection didn't hold, mark as false positive.
|
||||
|
||||
Args:
|
||||
evolution_record: Dict with player_id, detection_date, inflections.
|
||||
games_since_detection: Number of games played since detection.
|
||||
|
||||
Returns:
|
||||
Dict with promoted (bool) and reason.
|
||||
"""
|
||||
if games_since_detection < EVOLUTION_PERSISTENCE_GAMES:
|
||||
return {
|
||||
'promoted': False,
|
||||
'reason': f'Persistence check: {games_since_detection}/{EVOLUTION_PERSISTENCE_GAMES} games'
|
||||
}
|
||||
|
||||
# Check if inflection held (would verify against recent data in production)
|
||||
still_inflecting = verify_inflection_persists(evolution_record)
|
||||
|
||||
if still_inflecting:
|
||||
return {'promoted': True, 'games_verified': games_since_detection}
|
||||
else:
|
||||
return {
|
||||
'promoted': False,
|
||||
'reason': 'Inflection did not persist — false positive',
|
||||
'false_positive': True
|
||||
}
|
||||
|
||||
|
||||
def verify_inflection_persists(evolution_record):
|
||||
"""
|
||||
Verify that detected inflection points are still present in recent data.
|
||||
Returns True if the change direction is maintained.
|
||||
|
||||
Args:
|
||||
evolution_record: Dict with inflections data.
|
||||
|
||||
Returns:
|
||||
True if inflection persists, False if reverted.
|
||||
"""
|
||||
inflections = evolution_record.get('inflections', {})
|
||||
if not inflections:
|
||||
return False
|
||||
# In production: re-fetch recent metric values and compare to post-inflection mean
|
||||
# For now, stub returns True (would be replaced with actual data check)
|
||||
return True
|
||||
|
||||
|
||||
def scan_for_evolutions_internal(sport):
|
||||
"""
|
||||
Internal version of evolution scan called by nightly resolution.
|
||||
Does not require Flask request context.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
"""
|
||||
logger.info(f'[VYNDR] Running evolution scan for {sport}')
|
||||
# In production: iterate active players, call detect_player_evolution
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
VYNDR Image-to-Grade OCR
|
||||
Accept bet slip screenshot → preprocess → OCR → parse → fuzzy match → grade.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import io
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
image_grade_bp = Blueprint('image_grade', __name__)
|
||||
|
||||
|
||||
def preprocess_image(image_bytes):
|
||||
"""
|
||||
Preprocess image for OCR: grayscale, contrast enhancement, threshold.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes.
|
||||
|
||||
Returns:
|
||||
PIL Image ready for OCR.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
img = img.convert('L') # grayscale
|
||||
enhancer = ImageEnhance.Contrast(img)
|
||||
img = enhancer.enhance(2.0)
|
||||
img = img.filter(ImageFilter.SHARPEN)
|
||||
return img
|
||||
except ImportError:
|
||||
logger.error('[VYNDR] Pillow not installed')
|
||||
return None
|
||||
|
||||
|
||||
def ocr_image(image):
|
||||
"""
|
||||
Run OCR on preprocessed image.
|
||||
|
||||
Args:
|
||||
image: PIL Image.
|
||||
|
||||
Returns:
|
||||
Dict with text and confidence.
|
||||
"""
|
||||
try:
|
||||
import pytesseract
|
||||
text = pytesseract.image_to_string(image)
|
||||
data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT)
|
||||
confidences = [int(c) for c in data['conf'] if int(c) > 0]
|
||||
avg_conf = sum(confidences) / len(confidences) if confidences else 0
|
||||
return {'text': text.strip(), 'confidence': round(avg_conf, 1)}
|
||||
except ImportError:
|
||||
logger.error('[VYNDR] pytesseract not installed')
|
||||
return {'text': '', 'confidence': 0}
|
||||
except Exception as e:
|
||||
logger.error(f'[VYNDR] OCR failed: {e}')
|
||||
return {'text': '', 'confidence': 0}
|
||||
|
||||
|
||||
def parse_bet_slip(text):
|
||||
"""
|
||||
Parse OCR text to extract bet slip components.
|
||||
|
||||
Args:
|
||||
text: OCR extracted text.
|
||||
|
||||
Returns:
|
||||
List of parsed leg dicts with player, stat_type, line, over_under.
|
||||
"""
|
||||
legs = []
|
||||
lines = text.split('\n')
|
||||
|
||||
stat_keywords = {
|
||||
'pts': 'points', 'points': 'points', 'reb': 'rebounds',
|
||||
'rebounds': 'rebounds', 'ast': 'assists', 'assists': 'assists',
|
||||
'threes': 'threes', '3pt': 'threes', '3-pointers': 'threes',
|
||||
'strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k\'s': 'strikeouts',
|
||||
'hits': 'hits', 'total bases': 'total_bases', 'tb': 'total_bases',
|
||||
'rbi': 'rbi', 'home runs': 'home_runs', 'hr': 'home_runs',
|
||||
'walks': 'walks', 'bb': 'walks'
|
||||
}
|
||||
|
||||
for line in lines:
|
||||
line_lower = line.lower().strip()
|
||||
if not line_lower:
|
||||
continue
|
||||
|
||||
# Try to find over/under
|
||||
over_under = None
|
||||
if 'over' in line_lower:
|
||||
over_under = 'over'
|
||||
elif 'under' in line_lower:
|
||||
over_under = 'under'
|
||||
|
||||
# Try to find stat type
|
||||
stat_type = None
|
||||
for keyword, mapped in stat_keywords.items():
|
||||
if keyword in line_lower:
|
||||
stat_type = mapped
|
||||
break
|
||||
|
||||
# Try to find line value (number with optional .5)
|
||||
import re
|
||||
numbers = re.findall(r'\d+\.?\d*', line)
|
||||
prop_line = None
|
||||
for n in numbers:
|
||||
val = float(n)
|
||||
if 0.5 <= val <= 99.5:
|
||||
prop_line = val
|
||||
break
|
||||
|
||||
if stat_type and prop_line and over_under:
|
||||
# Player name is whatever text precedes the stat keyword
|
||||
legs.append({
|
||||
'raw_text': line.strip(),
|
||||
'stat_type': stat_type,
|
||||
'line': prop_line,
|
||||
'over_under': over_under,
|
||||
'player_name': None # needs fuzzy matching
|
||||
})
|
||||
|
||||
return legs
|
||||
|
||||
|
||||
@image_grade_bp.route('/from-image', methods=['POST'])
|
||||
def grade_from_image():
|
||||
"""
|
||||
Accept bet slip screenshot, OCR it, parse legs, and grade.
|
||||
|
||||
Request: multipart/form-data with 'image' file.
|
||||
|
||||
Returns:
|
||||
JSON with parsed legs, OCR confidence, and grades (or confirmation request).
|
||||
"""
|
||||
if 'image' not in request.files:
|
||||
return jsonify({'error': 'No image file provided'}), 400
|
||||
|
||||
image_file = request.files['image']
|
||||
image_bytes = image_file.read()
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
return jsonify({'error': 'Empty image file'}), 400
|
||||
|
||||
# Preprocess
|
||||
processed = preprocess_image(image_bytes)
|
||||
if processed is None:
|
||||
return jsonify({'error': 'Image processing failed'}), 500
|
||||
|
||||
# OCR
|
||||
ocr_result = ocr_image(processed)
|
||||
|
||||
# Parse
|
||||
legs = parse_bet_slip(ocr_result['text'])
|
||||
|
||||
# Low confidence — ask user to confirm
|
||||
if ocr_result['confidence'] < 60:
|
||||
return jsonify({
|
||||
'status': 'low_confidence',
|
||||
'ocr_confidence': ocr_result['confidence'],
|
||||
'extracted_text': ocr_result['text'],
|
||||
'parsed_legs': legs,
|
||||
'message': 'OCR confidence is low. Please confirm the extracted information.'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'parsed',
|
||||
'ocr_confidence': ocr_result['confidence'],
|
||||
'legs': legs,
|
||||
'leg_count': len(legs),
|
||||
'note': 'Legs parsed. Submit to /api/mlb/grade or /api/nba/grade for grading.'
|
||||
})
|
||||
@@ -0,0 +1,710 @@
|
||||
"""
|
||||
VYNDR Lineup Intelligence — Multi-source lineup monitoring.
|
||||
Blueprint providing real-time lineup status by aggregating official APIs,
|
||||
beat reporter tweets, and backup sources. Tracks reporter accuracy over time
|
||||
and promotes/demotes trust tiers dynamically.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.retry import api_call_with_retry
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
lineup_bp = Blueprint('lineup_intelligence', __name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source priority configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LINEUP_SOURCES = {
|
||||
'official_api': {
|
||||
'priority': 1,
|
||||
'description': 'Official league API (MLB statsapi, NBA official)',
|
||||
'badge_on_confirm': 'confirmed',
|
||||
},
|
||||
'beat_reporter': {
|
||||
'priority': 2,
|
||||
'description': 'Beat reporters and insiders — trust is dynamic',
|
||||
'badge_on_confirm': 'preliminary',
|
||||
},
|
||||
'backup_api': {
|
||||
'priority': 3,
|
||||
'description': 'Fallback third-party data feeds',
|
||||
'badge_on_confirm': 'preliminary',
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporter trust system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REPORTER_TRUST_TIERS = {
|
||||
'unverified': {
|
||||
'min_tracked': 0,
|
||||
'accuracy': 0.0,
|
||||
'badge': 'preliminary',
|
||||
},
|
||||
'reliable': {
|
||||
'min_tracked': 10,
|
||||
'accuracy': 0.80,
|
||||
'badge': 'preliminary',
|
||||
},
|
||||
'verified': {
|
||||
'min_tracked': 20,
|
||||
'accuracy': 0.90,
|
||||
'badge': 'high_confidence',
|
||||
},
|
||||
'authoritative': {
|
||||
'min_tracked': 30,
|
||||
'accuracy': 0.95,
|
||||
'badge': 'confirmed',
|
||||
},
|
||||
}
|
||||
|
||||
STARTING_TRUST = {
|
||||
'beat_writer': 'reliable',
|
||||
'national': 'authoritative',
|
||||
'insider': 'reliable',
|
||||
'aggregator': 'unverified',
|
||||
}
|
||||
|
||||
# In-memory reporter tracking — production would persist to Supabase.
|
||||
_reporter_stats = {}
|
||||
|
||||
# In-memory lineup cache keyed by (sport, game_date, game_id).
|
||||
_lineup_cache = {}
|
||||
|
||||
# In-memory reporter-to-line-movement correlation log.
|
||||
_reporter_line_correlations = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tweet parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LINEUP_KEYWORDS = {
|
||||
'confirmed_playing': [
|
||||
'will play', 'starting', 'in the lineup', 'cleared to play',
|
||||
'available tonight', 'is a go', 'will start', 'expected to play',
|
||||
'in tonight', 'active tonight',
|
||||
],
|
||||
'scratched': [
|
||||
'scratched', 'out tonight', 'will not play', 'ruled out',
|
||||
'sits tonight', 'will miss', 'inactive', 'dnp', 'is out',
|
||||
'not in lineup', 'held out',
|
||||
],
|
||||
'questionable': [
|
||||
'questionable', 'game-time decision', 'gtd', 'uncertain',
|
||||
'doubtful', 'may sit', 'TBD', 'monitor', 'day-to-day',
|
||||
'not certain',
|
||||
],
|
||||
}
|
||||
|
||||
PAST_TENSE_FILTERS = [
|
||||
'played', 'started', 'was scratched', 'sat out', 'missed',
|
||||
'did not play', 'was ruled out', 'was inactive', 'had',
|
||||
'finished', 'went for', 'scored', 'posted',
|
||||
]
|
||||
|
||||
|
||||
def parse_reporter_tweet(tweet_text, tweet_date=None):
|
||||
"""
|
||||
Parse a reporter tweet for lineup-relevant information.
|
||||
|
||||
Filters out past-tense recaps and tweets that do not reference today's
|
||||
games. Returns a dict with player mentions, detected status, and the
|
||||
raw keyword match, or None if the tweet is not actionable.
|
||||
|
||||
Args:
|
||||
tweet_text: Raw text content of the tweet.
|
||||
tweet_date: Date the tweet was posted (datetime.date). Defaults to
|
||||
today if not provided.
|
||||
|
||||
Returns:
|
||||
dict with keys {status, keywords_matched, raw_text} or None.
|
||||
"""
|
||||
if tweet_date is None:
|
||||
tweet_date = date.today()
|
||||
|
||||
if tweet_date != date.today():
|
||||
logger.debug('Skipping tweet from non-today date: %s', tweet_date)
|
||||
return None
|
||||
|
||||
lower = tweet_text.lower()
|
||||
|
||||
# Filter past-tense recaps
|
||||
for phrase in PAST_TENSE_FILTERS:
|
||||
if phrase in lower:
|
||||
logger.debug('Filtered past-tense tweet: %s', tweet_text[:80])
|
||||
return None
|
||||
|
||||
# Detect lineup status keywords
|
||||
for status, keywords in LINEUP_KEYWORDS.items():
|
||||
matched = [kw for kw in keywords if kw.lower() in lower]
|
||||
if matched:
|
||||
return {
|
||||
'status': status,
|
||||
'keywords_matched': matched,
|
||||
'raw_text': tweet_text,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporter trust management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_reporter_record(reporter_handle):
|
||||
"""
|
||||
Retrieve or initialize the tracking record for a reporter.
|
||||
|
||||
Args:
|
||||
reporter_handle: Twitter/X handle of the reporter.
|
||||
|
||||
Returns:
|
||||
dict with keys {handle, total, correct, tier}.
|
||||
"""
|
||||
if reporter_handle not in _reporter_stats:
|
||||
_reporter_stats[reporter_handle] = {
|
||||
'handle': reporter_handle,
|
||||
'total': 0,
|
||||
'correct': 0,
|
||||
'tier': 'unverified',
|
||||
}
|
||||
return _reporter_stats[reporter_handle]
|
||||
|
||||
|
||||
def update_reporter_trust(reporter_handle, was_correct):
|
||||
"""
|
||||
Update a reporter's accuracy tracking and promote/demote their tier.
|
||||
|
||||
Called after an official source confirms or contradicts a reporter's
|
||||
earlier lineup call. Walks through REPORTER_TRUST_TIERS from highest
|
||||
to lowest and assigns the best tier the reporter qualifies for.
|
||||
|
||||
Args:
|
||||
reporter_handle: Twitter/X handle of the reporter.
|
||||
was_correct: Boolean — did the official source confirm the call?
|
||||
|
||||
Returns:
|
||||
dict with {handle, tier, accuracy, total}.
|
||||
"""
|
||||
record = _get_reporter_record(reporter_handle)
|
||||
record['total'] += 1
|
||||
if was_correct:
|
||||
record['correct'] += 1
|
||||
|
||||
accuracy = record['correct'] / record['total'] if record['total'] > 0 else 0.0
|
||||
|
||||
# Walk tiers from best to worst, assign the highest that qualifies.
|
||||
tier_order = ['authoritative', 'verified', 'reliable', 'unverified']
|
||||
assigned_tier = 'unverified'
|
||||
for tier_name in tier_order:
|
||||
tier_def = REPORTER_TRUST_TIERS[tier_name]
|
||||
if (record['total'] >= tier_def['min_tracked']
|
||||
and accuracy >= tier_def['accuracy']):
|
||||
assigned_tier = tier_name
|
||||
break
|
||||
|
||||
record['tier'] = assigned_tier
|
||||
logger.info(
|
||||
'Reporter %s updated: tier=%s accuracy=%.2f total=%d',
|
||||
reporter_handle, assigned_tier, accuracy, record['total'],
|
||||
)
|
||||
|
||||
return {
|
||||
'handle': reporter_handle,
|
||||
'tier': assigned_tier,
|
||||
'accuracy': round(accuracy, 4),
|
||||
'total': record['total'],
|
||||
}
|
||||
|
||||
|
||||
def get_reporter_badge(reporter_handle):
|
||||
"""
|
||||
Return the display badge for a reporter based on their current trust tier.
|
||||
|
||||
The badge maps directly from REPORTER_TRUST_TIERS and controls how the
|
||||
frontend labels lineup intel sourced from this reporter.
|
||||
|
||||
Args:
|
||||
reporter_handle: Twitter/X handle of the reporter.
|
||||
|
||||
Returns:
|
||||
str badge value (e.g. 'preliminary', 'high_confidence', 'confirmed').
|
||||
"""
|
||||
record = _get_reporter_record(reporter_handle)
|
||||
tier = record.get('tier', 'unverified')
|
||||
return REPORTER_TRUST_TIERS.get(tier, REPORTER_TRUST_TIERS['unverified'])['badge']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two-stage lineup grading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_lineup_update(game_id, sport, player_name, status, source_type,
|
||||
reporter_handle=None):
|
||||
"""
|
||||
Two-stage lineup grading pipeline.
|
||||
|
||||
Stage 1 (beat_reporter / backup_api): Record the update with a
|
||||
preliminary badge. The confidence depends on the reporter's trust tier.
|
||||
|
||||
Stage 2 (official_api): Stamp the update with a confirmed badge and
|
||||
back-validate any earlier reporter calls for that player/game.
|
||||
|
||||
Args:
|
||||
game_id: Unique identifier for the game.
|
||||
sport: Sport key (e.g. 'mlb', 'nba').
|
||||
player_name: Full player name.
|
||||
status: One of 'confirmed_playing', 'scratched', 'questionable'.
|
||||
source_type: Key from LINEUP_SOURCES ('official_api', 'beat_reporter',
|
||||
'backup_api').
|
||||
reporter_handle: Required when source_type is 'beat_reporter'.
|
||||
|
||||
Returns:
|
||||
dict with the stored lineup entry including badge and timestamp.
|
||||
"""
|
||||
cache_key = (sport, game_id, player_name.lower())
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
source_def = LINEUP_SOURCES.get(source_type)
|
||||
if source_def is None:
|
||||
logger.error('Unknown source_type: %s', source_type)
|
||||
return {'error': f'Unknown source_type: {source_type}'}
|
||||
|
||||
# Determine badge
|
||||
if source_type == 'official_api':
|
||||
badge = 'confirmed'
|
||||
elif source_type == 'beat_reporter' and reporter_handle:
|
||||
badge = get_reporter_badge(reporter_handle)
|
||||
else:
|
||||
badge = source_def.get('badge_on_confirm', 'preliminary')
|
||||
|
||||
entry = {
|
||||
'game_id': game_id,
|
||||
'sport': sport,
|
||||
'player': player_name,
|
||||
'status': status,
|
||||
'source': source_type,
|
||||
'reporter': reporter_handle,
|
||||
'badge': badge,
|
||||
'timestamp': now,
|
||||
}
|
||||
|
||||
existing = _lineup_cache.get(cache_key)
|
||||
|
||||
# Stage 2: official confirmation — back-validate reporter calls.
|
||||
if source_type == 'official_api' and existing:
|
||||
prior_source = existing.get('source')
|
||||
prior_reporter = existing.get('reporter')
|
||||
if prior_source == 'beat_reporter' and prior_reporter:
|
||||
was_correct = existing.get('status') == status
|
||||
update_reporter_trust(prior_reporter, was_correct)
|
||||
logger.info(
|
||||
'Back-validated reporter %s for %s: correct=%s',
|
||||
prior_reporter, player_name, was_correct,
|
||||
)
|
||||
|
||||
# Only overwrite if the new source has equal or higher priority.
|
||||
if existing is None or source_def['priority'] <= LINEUP_SOURCES.get(
|
||||
existing.get('source', ''), {}).get('priority', 99):
|
||||
_lineup_cache[cache_key] = entry
|
||||
logger.info(
|
||||
'Lineup update stored: %s %s -> %s [%s]',
|
||||
player_name, status, source_type, badge,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
'Skipped lower-priority update for %s from %s',
|
||||
player_name, source_type,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH: Scratch → Redistribution → Re-grade → Alt Line → Alert chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_scratch_chain(player_name, player_id, team, game_id, sport, badge):
|
||||
"""
|
||||
Full chain when a player is confirmed OUT:
|
||||
1. Trigger redistribution engine for absorption analysis
|
||||
2. Re-grade affected props with redistribution context
|
||||
3. Auto-scan alt lines for any A-grade re-grades
|
||||
4. Format and return alert with all intelligence
|
||||
|
||||
Args:
|
||||
player_name: Scratched player name.
|
||||
player_id: Scratched player ID.
|
||||
team: Team identifier.
|
||||
game_id: Game identifier.
|
||||
sport: 'nba' or 'mlb'.
|
||||
badge: Reporter badge level.
|
||||
|
||||
Returns:
|
||||
Dict with redistribution, re-graded props, and alt line opportunities.
|
||||
"""
|
||||
result = {
|
||||
'player_scratched': player_name,
|
||||
'redistribution': None,
|
||||
'regraded_props': [],
|
||||
'alt_opportunities': [],
|
||||
'alert': None
|
||||
}
|
||||
|
||||
# Step 1: Redistribution
|
||||
try:
|
||||
from blueprints.redistribution import calculate_redistribution_internal
|
||||
redistribution = calculate_redistribution_internal(player_id, game_id)
|
||||
result['redistribution'] = redistribution
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Redistribution chain failed: {e}')
|
||||
redistribution = None
|
||||
|
||||
# Step 2: Re-grade affected props (stub — connects to grading engine)
|
||||
# In production, get_props_affected_by_scratch returns live props
|
||||
# and recalculate_grade runs the full pipeline with redistribution_context
|
||||
|
||||
# Step 3: Alt line scan for A-grade re-grades
|
||||
try:
|
||||
from blueprints.odds_scanner import scan_alt_lines_internal
|
||||
for prop in result.get('regraded_props', []):
|
||||
if prop.get('grade') in ['A+', 'A', 'A-']:
|
||||
alt = scan_alt_lines_internal(
|
||||
sport, prop.get('player', ''),
|
||||
prop.get('stat_type', ''),
|
||||
standard_grade=prop
|
||||
)
|
||||
if alt.get('recommend_alt'):
|
||||
result['alt_opportunities'].append(alt)
|
||||
prop['alt_line_opportunity'] = alt.get('optimal_alt')
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Alt line chain failed: {e}')
|
||||
|
||||
# Step 4: Format alert
|
||||
if redistribution and redistribution.get('primary_beneficiary'):
|
||||
primary = redistribution['primary_beneficiary']
|
||||
alert = (
|
||||
f"{player_name} is OUT.\n"
|
||||
f"{primary.get('player_name', '?')} is underpriced. "
|
||||
f"Boost: +{primary.get('combined_prop_boost', 0):.0%}. "
|
||||
f"Confidence: {primary.get('confidence', 0):.0%}."
|
||||
)
|
||||
if result['alt_opportunities']:
|
||||
alt = result['alt_opportunities'][0].get('optimal_alt', {})
|
||||
alert += (
|
||||
f"\n\nAlt line: {alt.get('over_under', '').upper()} "
|
||||
f"{alt.get('line', '?')} at {alt.get('odds', '?')} "
|
||||
f"\u2192 Edge: {alt.get('real_edge', 0):.1%}"
|
||||
)
|
||||
result['alert'] = alert
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def poll_reporter_feeds(sport):
|
||||
"""
|
||||
Poll reporter feeds for lineup updates. Called by GitHub Actions cron.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
"""
|
||||
logger.info(f'[VYNDR] Polling reporter feeds for {sport}')
|
||||
# In production, fetch from Twitter API / RSS feeds
|
||||
# Parse via parse_reporter_tweet, process via process_lineup_update
|
||||
|
||||
|
||||
def check_all_lineups(sport):
|
||||
"""
|
||||
Check all lineup statuses from official APIs. Called by pre-game cron.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
"""
|
||||
logger.info(f'[VYNDR] Checking all lineups for {sport}')
|
||||
# In production, fetch from official MLB/NBA APIs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporter-to-line-movement correlation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def log_reporter_line_correlation(reporter_handle, game_id, player_name,
|
||||
tweet_timestamp, line_move_timestamp,
|
||||
line_before, line_after):
|
||||
"""
|
||||
Track the time gap between a reporter's tweet and subsequent book line
|
||||
movement. Used to measure how quickly the market prices reporter intel.
|
||||
|
||||
Args:
|
||||
reporter_handle: Twitter/X handle.
|
||||
game_id: Unique game identifier.
|
||||
player_name: Player referenced in the tweet.
|
||||
tweet_timestamp: ISO timestamp of the tweet.
|
||||
line_move_timestamp: ISO timestamp of the detected line move.
|
||||
line_before: Odds/line value before the move.
|
||||
line_after: Odds/line value after the move.
|
||||
|
||||
Returns:
|
||||
dict with the correlation record including gap_seconds.
|
||||
"""
|
||||
try:
|
||||
tweet_dt = datetime.fromisoformat(tweet_timestamp)
|
||||
move_dt = datetime.fromisoformat(line_move_timestamp)
|
||||
gap_seconds = (move_dt - tweet_dt).total_seconds()
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning('Could not compute gap for %s: %s', reporter_handle, exc)
|
||||
gap_seconds = None
|
||||
|
||||
record = {
|
||||
'reporter': reporter_handle,
|
||||
'game_id': game_id,
|
||||
'player': player_name,
|
||||
'tweet_timestamp': tweet_timestamp,
|
||||
'line_move_timestamp': line_move_timestamp,
|
||||
'line_before': line_before,
|
||||
'line_after': line_after,
|
||||
'gap_seconds': gap_seconds,
|
||||
}
|
||||
|
||||
_reporter_line_correlations.append(record)
|
||||
logger.info(
|
||||
'Line correlation logged: reporter=%s player=%s gap=%.1fs line %s->%s',
|
||||
reporter_handle, player_name,
|
||||
gap_seconds if gap_seconds is not None else -1,
|
||||
line_before, line_after,
|
||||
)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MLB lineup parsing via statsapi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_mlb_lineups_today(game_date=None):
|
||||
"""
|
||||
Fetch today's MLB starting lineups from the official statsapi.
|
||||
|
||||
Uses utils.retry for resilience and utils.data_warehouse for caching
|
||||
(15-minute TTL since lineups can change close to game time).
|
||||
|
||||
Args:
|
||||
game_date: Date string in 'YYYY-MM-DD' format. Defaults to today.
|
||||
|
||||
Returns:
|
||||
list of dicts, one per game, each containing home/away lineup arrays.
|
||||
"""
|
||||
if game_date is None:
|
||||
game_date = date.today().strftime('%Y-%m-%d')
|
||||
|
||||
cache_key = f'mlb_lineups_{game_date}'
|
||||
|
||||
def _fetch():
|
||||
"""Inner fetch wrapped for retry and caching."""
|
||||
try:
|
||||
import statsapi
|
||||
except ImportError:
|
||||
logger.error('statsapi not installed — cannot fetch MLB lineups')
|
||||
return []
|
||||
|
||||
schedule = api_call_with_retry(
|
||||
lambda: statsapi.schedule(date=game_date),
|
||||
max_retries=3,
|
||||
label='statsapi.schedule',
|
||||
)
|
||||
|
||||
if not schedule:
|
||||
logger.warning('No MLB games found for %s', game_date)
|
||||
return []
|
||||
|
||||
games = []
|
||||
for game in schedule:
|
||||
game_id = game.get('game_id')
|
||||
if game_id is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
boxscore = api_call_with_retry(
|
||||
lambda gid=game_id: statsapi.boxscore_data(gid),
|
||||
max_retries=3,
|
||||
label='statsapi.boxscore_data',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to get boxscore for game %s: %s', game_id, exc)
|
||||
continue
|
||||
|
||||
home_lineup = []
|
||||
away_lineup = []
|
||||
|
||||
for side, lineup_list in [('home', home_lineup), ('away', away_lineup)]:
|
||||
batters_key = f'{side}Batters'
|
||||
batters = boxscore.get(batters_key, [])
|
||||
for batter in batters:
|
||||
if isinstance(batter, dict):
|
||||
name = batter.get('name', batter.get('namefield', ''))
|
||||
if name:
|
||||
lineup_list.append({
|
||||
'name': name.strip(),
|
||||
'position': batter.get('position', ''),
|
||||
'batting_order': batter.get('battingOrder', ''),
|
||||
})
|
||||
|
||||
games.append({
|
||||
'game_id': game_id,
|
||||
'home_team': game.get('home_name', ''),
|
||||
'away_team': game.get('away_name', ''),
|
||||
'game_time': game.get('game_datetime', ''),
|
||||
'status': game.get('status', ''),
|
||||
'home_lineup': home_lineup,
|
||||
'away_lineup': away_lineup,
|
||||
})
|
||||
|
||||
logger.info('Fetched %d MLB game lineups for %s', len(games), game_date)
|
||||
return games
|
||||
|
||||
return fetch_with_cache(cache_key, _fetch, ttl=900)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@lineup_bp.route('/status/<sport>/<game_date>', methods=['GET'])
|
||||
def lineup_status(sport, game_date):
|
||||
"""
|
||||
Return lineup status for all tracked games in a sport on a given date.
|
||||
|
||||
Pulls from the in-memory lineup cache and, for MLB, supplements with
|
||||
official statsapi data. Results include the confidence badge for each
|
||||
player entry.
|
||||
|
||||
Args:
|
||||
sport: Sport key ('mlb', 'nba', 'nfl', etc.).
|
||||
game_date: Date string 'YYYY-MM-DD'.
|
||||
|
||||
Returns:
|
||||
JSON response with lineup entries grouped by game.
|
||||
"""
|
||||
try:
|
||||
target_date = datetime.strptime(game_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD.'}), 400
|
||||
|
||||
# Gather cached entries for the sport/date
|
||||
entries = []
|
||||
for (cached_sport, cached_game, _player), entry in _lineup_cache.items():
|
||||
if cached_sport == sport:
|
||||
entries.append(entry)
|
||||
|
||||
# For MLB, supplement with official lineups if available
|
||||
if sport == 'mlb':
|
||||
try:
|
||||
official_lineups = get_mlb_lineups_today(game_date)
|
||||
for game in official_lineups:
|
||||
for side in ['home_lineup', 'away_lineup']:
|
||||
for player in game.get(side, []):
|
||||
process_lineup_update(
|
||||
game_id=str(game['game_id']),
|
||||
sport='mlb',
|
||||
player_name=player['name'],
|
||||
status='confirmed_playing',
|
||||
source_type='official_api',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('MLB official lineup fetch failed: %s', exc)
|
||||
|
||||
# Re-gather after potential official update
|
||||
result = {}
|
||||
for (cached_sport, cached_game, _player), entry in _lineup_cache.items():
|
||||
if cached_sport == sport:
|
||||
result.setdefault(cached_game, []).append(entry)
|
||||
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'date': game_date,
|
||||
'games': result,
|
||||
'total_entries': sum(len(v) for v in result.values()),
|
||||
})
|
||||
|
||||
|
||||
@lineup_bp.route('/reporter-update', methods=['POST'])
|
||||
def reporter_update():
|
||||
"""
|
||||
Process a reporter tweet and store the lineup update.
|
||||
|
||||
Expects JSON body:
|
||||
{
|
||||
"reporter_handle": "@handle",
|
||||
"reporter_type": "beat_writer" | "national" | "insider" | "aggregator",
|
||||
"tweet_text": "Player X will play tonight...",
|
||||
"tweet_date": "YYYY-MM-DD" (optional, defaults to today),
|
||||
"game_id": "game_123",
|
||||
"sport": "mlb",
|
||||
"player_name": "Player X"
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with the parsed tweet result and stored lineup entry, or an
|
||||
error if the tweet was filtered or unparseable.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'error': 'Request body must be JSON.'}), 400
|
||||
|
||||
required = ['reporter_handle', 'tweet_text', 'game_id', 'sport', 'player_name']
|
||||
missing = [f for f in required if f not in data]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing required fields: {missing}'}), 400
|
||||
|
||||
reporter_handle = data['reporter_handle']
|
||||
reporter_type = data.get('reporter_type', 'aggregator')
|
||||
tweet_text = data['tweet_text']
|
||||
game_id = data['game_id']
|
||||
sport = data['sport']
|
||||
player_name = data['player_name']
|
||||
|
||||
# Parse tweet date
|
||||
tweet_date = None
|
||||
if data.get('tweet_date'):
|
||||
try:
|
||||
tweet_date = datetime.strptime(data['tweet_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid tweet_date format. Use YYYY-MM-DD.'}), 400
|
||||
|
||||
# Initialize reporter trust if first time seeing them
|
||||
record = _get_reporter_record(reporter_handle)
|
||||
if record['total'] == 0 and reporter_type in STARTING_TRUST:
|
||||
record['tier'] = STARTING_TRUST[reporter_type]
|
||||
|
||||
# Parse the tweet
|
||||
parsed = parse_reporter_tweet(tweet_text, tweet_date=tweet_date)
|
||||
if parsed is None:
|
||||
return jsonify({
|
||||
'filtered': True,
|
||||
'reason': 'Tweet filtered (past tense, non-today, or no lineup keywords).',
|
||||
}), 200
|
||||
|
||||
# Store lineup update
|
||||
entry = process_lineup_update(
|
||||
game_id=game_id,
|
||||
sport=sport,
|
||||
player_name=player_name,
|
||||
status=parsed['status'],
|
||||
source_type='beat_reporter',
|
||||
reporter_handle=reporter_handle,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'filtered': False,
|
||||
'parsed': parsed,
|
||||
'lineup_entry': entry,
|
||||
'reporter_badge': get_reporter_badge(reporter_handle),
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
VYNDR NBA Context Service
|
||||
Teammate impact, game script, home/road splits, rest/travel, matchup pace,
|
||||
foul trouble risk, B2B adjustments, positional matchup defense,
|
||||
usage-efficiency tradeoff. NBA sub-scores endpoint.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.archetypes import (
|
||||
NBA_DIMENSIONS, DEFAULT_NBA_WEIGHTS, NBA_SUB_SCORES,
|
||||
get_archetype_scores, blend_archetype_weights
|
||||
)
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
nba_context_bp = Blueprint('nba_context', __name__)
|
||||
|
||||
NBA_API_DELAY = 0.6
|
||||
|
||||
# --- Teammate Impact ---
|
||||
|
||||
TEAMMATE_IMPACT_RULES = {
|
||||
'primary_ball_handler_out': {
|
||||
'remaining_playmaker': {'base_usage_boost': 0.04, 'assist_boost': 1.5},
|
||||
'remaining_scorers': {'base_usage_boost': 0.02, 'fg_attempts_boost': 1.8}
|
||||
},
|
||||
'primary_scorer_out': {
|
||||
'secondary_scorers': {'base_usage_boost': 0.05, 'fg_attempts_boost': 2.5},
|
||||
'playmaker': {'assist_reduction': -0.8}
|
||||
},
|
||||
'starting_big_out': {
|
||||
'backup_big': {'minutes_boost': 12, 'rebound_boost': 3.0},
|
||||
'remaining_bigs': {'rebound_boost': 1.5}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def calculate_dynamic_usage_boost(out_player_archetype, beneficiary_profile, base_boost):
|
||||
"""
|
||||
Scale usage boost by beneficiary's headroom. Player at 20% usage has
|
||||
more room to absorb than player at 35%.
|
||||
|
||||
Args:
|
||||
out_player_archetype: Archetype of the absent player.
|
||||
beneficiary_profile: Dict with usage_rate for the beneficiary.
|
||||
base_boost: Base usage boost from TEAMMATE_IMPACT_RULES.
|
||||
|
||||
Returns:
|
||||
Float — scaled usage boost.
|
||||
"""
|
||||
usage_ceiling = 0.38
|
||||
current_usage = beneficiary_profile.get('usage_rate', 0.20)
|
||||
headroom = max(0, usage_ceiling - current_usage)
|
||||
headroom_factor = min(1.0, headroom / 0.15)
|
||||
return round(base_boost * headroom_factor, 3)
|
||||
|
||||
|
||||
def adjust_for_usage_efficiency_tradeoff(usage_boost, player_profile):
|
||||
"""
|
||||
Higher usage often means lower efficiency. ~-1.5% TS per +5% usage increase.
|
||||
Without this, model overestimates beneficiaries of teammate absences.
|
||||
|
||||
Args:
|
||||
usage_boost: Float usage increase.
|
||||
player_profile: Dict with player stats.
|
||||
|
||||
Returns:
|
||||
Dict with volume_boost, efficiency_penalty, net_effect.
|
||||
"""
|
||||
ts_penalty_per_5pct_usage = -0.015
|
||||
projected_ts_change = usage_boost * (ts_penalty_per_5pct_usage / 0.05)
|
||||
return {
|
||||
'volume_boost': usage_boost,
|
||||
'efficiency_penalty': projected_ts_change,
|
||||
'net_effect': usage_boost + projected_ts_change
|
||||
}
|
||||
|
||||
|
||||
# --- Game Script ---
|
||||
|
||||
def adjust_minutes_for_spread(projected_minutes, spread, is_favorite):
|
||||
"""
|
||||
Adjust projected minutes based on game spread (blowout risk).
|
||||
|
||||
Args:
|
||||
projected_minutes: Base projected minutes.
|
||||
spread: Point spread (positive number).
|
||||
is_favorite: Whether the player's team is favored.
|
||||
|
||||
Returns:
|
||||
Adjusted projected minutes.
|
||||
"""
|
||||
if abs(spread) >= 12:
|
||||
return projected_minutes * (0.92 if is_favorite else 0.95)
|
||||
elif abs(spread) >= 8 and is_favorite:
|
||||
return projected_minutes * 0.96
|
||||
return projected_minutes
|
||||
|
||||
|
||||
# --- Home/Road Splits ---
|
||||
|
||||
def calculate_home_road_adjustment(player_splits, stat_type, is_home_game):
|
||||
"""
|
||||
Apply home/road split as context adjustment. Only when >5% difference.
|
||||
|
||||
Args:
|
||||
player_splits: Dict with {stat_type}_home and {stat_type}_road keys.
|
||||
stat_type: Stat type string.
|
||||
is_home_game: Boolean.
|
||||
|
||||
Returns:
|
||||
Float adjustment to projected value.
|
||||
"""
|
||||
home_avg = player_splits.get(f'{stat_type}_home')
|
||||
road_avg = player_splits.get(f'{stat_type}_road')
|
||||
if home_avg is None or road_avg is None:
|
||||
return 0.0
|
||||
overall_avg = (home_avg + road_avg) / 2
|
||||
if overall_avg == 0:
|
||||
return 0.0
|
||||
if abs(home_avg - road_avg) / overall_avg < 0.05:
|
||||
return 0.0
|
||||
return (home_avg if is_home_game else road_avg) - overall_avg
|
||||
|
||||
|
||||
# --- Rest + Travel Fatigue ---
|
||||
|
||||
REST_TRAVEL_ADJUSTMENT = {
|
||||
'same_timezone': 0.0,
|
||||
'one_tz_change': -0.01,
|
||||
'two_tz_change': -0.02,
|
||||
'three_tz_change': -0.03
|
||||
}
|
||||
|
||||
|
||||
def calculate_travel_fatigue(prev_game_tz_offset, current_tz_offset):
|
||||
"""
|
||||
Account for travel distance, not just rest days.
|
||||
BOS→LAL on a B2B is worse than BOS→NYK on a B2B.
|
||||
|
||||
Args:
|
||||
prev_game_tz_offset: UTC offset of previous game arena.
|
||||
current_tz_offset: UTC offset of current game arena.
|
||||
|
||||
Returns:
|
||||
Float adjustment (negative = fatigue penalty).
|
||||
"""
|
||||
if prev_game_tz_offset is None or current_tz_offset is None:
|
||||
return 0.0
|
||||
tz_diff = abs(current_tz_offset - prev_game_tz_offset)
|
||||
if tz_diff == 0:
|
||||
return REST_TRAVEL_ADJUSTMENT['same_timezone']
|
||||
elif tz_diff == 1:
|
||||
return REST_TRAVEL_ADJUSTMENT['one_tz_change']
|
||||
elif tz_diff == 2:
|
||||
return REST_TRAVEL_ADJUSTMENT['two_tz_change']
|
||||
else:
|
||||
return REST_TRAVEL_ADJUSTMENT['three_tz_change']
|
||||
|
||||
|
||||
# --- Matchup-Specific Pace ---
|
||||
|
||||
def calculate_matchup_pace(team_a_pace, team_b_pace, league_avg_pace, is_home):
|
||||
"""
|
||||
Matchup-specific pace — not just team averages.
|
||||
Two fast teams play FASTER than either team's average.
|
||||
Home team pace weighs 60/40.
|
||||
|
||||
Args:
|
||||
team_a_pace: Pace of the player's team.
|
||||
team_b_pace: Pace of the opponent.
|
||||
league_avg_pace: League average pace.
|
||||
is_home: Whether the player's team is home.
|
||||
|
||||
Returns:
|
||||
Float factor relative to league average (>1.0 = faster).
|
||||
"""
|
||||
if league_avg_pace <= 0:
|
||||
return 1.0
|
||||
if is_home:
|
||||
raw_pace = (team_a_pace * 0.60 + team_b_pace * 0.40)
|
||||
else:
|
||||
raw_pace = (team_a_pace * 0.40 + team_b_pace * 0.60)
|
||||
return raw_pace / league_avg_pace
|
||||
|
||||
|
||||
# --- Foul Trouble Risk ---
|
||||
|
||||
def foul_trouble_risk(fouls_per_game):
|
||||
"""
|
||||
Foul-prone players have wider minutes variance.
|
||||
Doesn't change the mean — widens the distribution.
|
||||
|
||||
Args:
|
||||
fouls_per_game: Season average fouls per game.
|
||||
|
||||
Returns:
|
||||
Dict with minutes_std_boost.
|
||||
"""
|
||||
if fouls_per_game >= 3.5:
|
||||
return {'minutes_std_boost': 3.0}
|
||||
elif fouls_per_game >= 2.8:
|
||||
return {'minutes_std_boost': 1.5}
|
||||
return {'minutes_std_boost': 0.0}
|
||||
|
||||
|
||||
# --- Stat-Specific B2B Adjustments ---
|
||||
|
||||
B2B_ADJUSTMENTS = {
|
||||
'points': -0.04,
|
||||
'rebounds': 0.02,
|
||||
'assists': -0.01,
|
||||
'threes': -0.03,
|
||||
'pts_reb_ast': -0.02,
|
||||
'default': -0.02
|
||||
}
|
||||
|
||||
|
||||
def apply_b2b_adjustment(projection, stat_type, is_b2b_second_game):
|
||||
"""
|
||||
B2B fatigue is NOT linear across stats.
|
||||
Points and threes drop. Rebounds actually increase.
|
||||
|
||||
Args:
|
||||
projection: Base projected value.
|
||||
stat_type: Stat type string.
|
||||
is_b2b_second_game: Whether this is the second game of a B2B.
|
||||
|
||||
Returns:
|
||||
Adjusted projection.
|
||||
"""
|
||||
if not is_b2b_second_game:
|
||||
return projection
|
||||
adj = B2B_ADJUSTMENTS.get(stat_type, B2B_ADJUSTMENTS['default'])
|
||||
return projection * (1 + adj)
|
||||
|
||||
|
||||
# --- Positional Matchup Defense ---
|
||||
|
||||
def calculate_positional_matchup(position_defenders, team_defensive_rating):
|
||||
"""
|
||||
Position-specific defensive quality, not just team rating.
|
||||
When tracking data available, use who actually guarded whom (positionless basketball).
|
||||
|
||||
Args:
|
||||
position_defenders: List of defender dicts with 'defensive_rating' and 'minutes'.
|
||||
team_defensive_rating: Fallback team-level defensive rating.
|
||||
|
||||
Returns:
|
||||
Float defensive rating for this matchup.
|
||||
"""
|
||||
if not position_defenders:
|
||||
return team_defensive_rating
|
||||
|
||||
weighted_def = sum(
|
||||
p.get('defensive_rating', team_defensive_rating) * p.get('minutes', 20)
|
||||
for p in position_defenders
|
||||
)
|
||||
total_min = sum(p.get('minutes', 20) for p in position_defenders)
|
||||
return weighted_def / total_min if total_min > 0 else team_defensive_rating
|
||||
|
||||
|
||||
# --- Playoff Modifiers ---
|
||||
|
||||
PLAYOFF_MODIFIERS = {
|
||||
'starter_minutes_boost': 1.10,
|
||||
'bench_dnp_threshold': 8,
|
||||
'primary_scorer_fg_penalty': 0.96,
|
||||
'primary_scorer_fta_boost': 1.10,
|
||||
'elimination_star_pts_boost': 1.05,
|
||||
'elimination_star_min_boost': 1.08,
|
||||
'rest_1_day': 'fatigue',
|
||||
'rest_4_plus_days': 'rust_flag'
|
||||
}
|
||||
|
||||
|
||||
def apply_playoff_modifiers(projection, stat_type, game_context, player_profile):
|
||||
"""
|
||||
Apply playoff-specific modifiers to projection.
|
||||
|
||||
Args:
|
||||
projection: Base projected value.
|
||||
stat_type: Stat type string.
|
||||
game_context: Dict with playoff info (is_elimination, is_home, etc.).
|
||||
player_profile: Dict with player stats.
|
||||
|
||||
Returns:
|
||||
Modified projection.
|
||||
"""
|
||||
if not game_context.get('is_playoff'):
|
||||
return projection
|
||||
|
||||
# Starters get more minutes
|
||||
if player_profile.get('is_starter'):
|
||||
projection *= PLAYOFF_MODIFIERS['starter_minutes_boost']
|
||||
|
||||
# Elimination game — star players elevate
|
||||
if game_context.get('is_elimination') and player_profile.get('usage_rate', 0) > 0.25:
|
||||
if stat_type == 'points':
|
||||
projection *= PLAYOFF_MODIFIERS['elimination_star_pts_boost']
|
||||
|
||||
return projection
|
||||
|
||||
|
||||
# --- NBA Sub-Scores Endpoint ---
|
||||
|
||||
@nba_context_bp.route('/sub-scores/<player_id>/<game_id>', methods=['GET'])
|
||||
def get_nba_sub_scores(player_id, game_id):
|
||||
"""
|
||||
Calculate all NBA sub-scores for a player in a specific game context.
|
||||
Returns individual sub-scores that the Node.js engine weights via archetypes.
|
||||
|
||||
Args:
|
||||
player_id: NBA player ID.
|
||||
game_id: NBA game ID.
|
||||
|
||||
Returns:
|
||||
JSON with sub_scores, archetype_scores, and blended_weights.
|
||||
"""
|
||||
stat_type = request.args.get('stat_type', 'points')
|
||||
is_home = request.args.get('is_home', 'true').lower() == 'true'
|
||||
|
||||
# Build player profile (would come from nba_api in production)
|
||||
player_profile = _get_player_profile_cached(player_id)
|
||||
game_context = _get_game_context_cached(game_id)
|
||||
|
||||
# Calculate each sub-score
|
||||
recent_form = _calculate_recent_form(player_id, stat_type)
|
||||
matchup_defense = _calculate_matchup_defense_score(player_id, game_context)
|
||||
pace_factor = _calculate_pace_score(player_profile, game_context, is_home)
|
||||
usage_context = _calculate_usage_score(player_id, game_context)
|
||||
home_road = calculate_home_road_adjustment(
|
||||
player_profile.get('splits', {}), stat_type, is_home
|
||||
)
|
||||
rest_travel_score = _calculate_rest_travel_score(player_profile, game_context)
|
||||
|
||||
sub_scores = {
|
||||
'recent_form': round(recent_form, 3),
|
||||
'matchup_defense': round(matchup_defense, 3),
|
||||
'pace_factor': round(pace_factor, 3),
|
||||
'usage_context': round(usage_context, 3),
|
||||
'home_road': round(home_road, 3),
|
||||
'rest_travel': round(rest_travel_score, 3)
|
||||
}
|
||||
|
||||
archetype_scores = get_archetype_scores(player_profile, NBA_DIMENSIONS)
|
||||
blended_weights = blend_archetype_weights(player_profile, NBA_DIMENSIONS, DEFAULT_NBA_WEIGHTS)
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'game_id': game_id,
|
||||
'stat_type': stat_type,
|
||||
'sub_scores': sub_scores,
|
||||
'archetype_scores': {k: round(v, 3) for k, v in archetype_scores.items()},
|
||||
'blended_weights': {k: round(v, 3) for k, v in blended_weights.items()}
|
||||
})
|
||||
|
||||
|
||||
@nba_context_bp.route('/teammate-impact/<player_id>/<game_id>', methods=['GET'])
|
||||
def get_teammate_impact(player_id, game_id):
|
||||
"""Get teammate impact for a player given tonight's injury report."""
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'game_id': game_id,
|
||||
'impact': {},
|
||||
'note': 'Requires live injury report data'
|
||||
})
|
||||
|
||||
|
||||
@nba_context_bp.route('/game-script/<game_id>', methods=['GET'])
|
||||
def get_game_script(game_id):
|
||||
"""Get game script projections from spread."""
|
||||
return jsonify({
|
||||
'game_id': game_id,
|
||||
'spread': None,
|
||||
'minutes_adjustments': {},
|
||||
'note': 'Requires odds data'
|
||||
})
|
||||
|
||||
|
||||
@nba_context_bp.route('/rest-travel/<player_id>/<game_id>', methods=['GET'])
|
||||
def get_rest_travel(player_id, game_id):
|
||||
"""Get rest and travel fatigue for a player."""
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'game_id': game_id,
|
||||
'rest_days': None,
|
||||
'travel_fatigue_adj': 0.0,
|
||||
'note': 'Requires schedule data'
|
||||
})
|
||||
|
||||
|
||||
# --- Internal Helpers ---
|
||||
|
||||
def _get_player_profile_cached(player_id):
|
||||
"""Get or build player profile from cache/API."""
|
||||
cached = fetch_with_cache(
|
||||
f'nba_profile_{player_id}',
|
||||
lambda: _fetch_player_profile(player_id),
|
||||
data_type='player_stats'
|
||||
)
|
||||
return cached or {}
|
||||
|
||||
|
||||
def _fetch_player_profile(player_id):
|
||||
"""Fetch player profile from nba_api."""
|
||||
time.sleep(NBA_API_DELAY)
|
||||
try:
|
||||
from nba_api.stats.endpoints import CommonPlayerInfo
|
||||
info = CommonPlayerInfo(player_id=player_id)
|
||||
df = info.get_data_frames()[0]
|
||||
if df.empty:
|
||||
return {}
|
||||
row = df.iloc[0]
|
||||
return {
|
||||
'player_id': player_id,
|
||||
'name': row.get('DISPLAY_FIRST_LAST', ''),
|
||||
'team_id': str(row.get('TEAM_ID', '')),
|
||||
'position': row.get('POSITION', ''),
|
||||
'usage_rate': 0.20, # populated from team stats
|
||||
'assist_rate': 0.15,
|
||||
'three_pa_rate': 0.30,
|
||||
'fg_pct': 0.45,
|
||||
'reb_per_game': 4.0,
|
||||
'splits': {}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Player profile fetch failed: {e}')
|
||||
return {}
|
||||
|
||||
|
||||
def _get_game_context_cached(game_id):
|
||||
"""Get game context from cache."""
|
||||
return fetch_with_cache(
|
||||
f'nba_game_{game_id}',
|
||||
lambda: {'game_id': game_id},
|
||||
data_type='player_stats'
|
||||
) or {}
|
||||
|
||||
|
||||
def _calculate_recent_form(player_id, stat_type):
|
||||
"""Calculate recent form score (0.0-1.0)."""
|
||||
return 0.50 # Neutral default; populated with real data via nba_api
|
||||
|
||||
|
||||
def _calculate_matchup_defense_score(player_id, game_context):
|
||||
"""Calculate matchup defense score (0.0-1.0)."""
|
||||
return 0.50
|
||||
|
||||
|
||||
def _calculate_pace_score(player_profile, game_context, is_home):
|
||||
"""Calculate pace factor score."""
|
||||
return 0.50
|
||||
|
||||
|
||||
def _calculate_usage_score(player_id, game_context):
|
||||
"""Calculate usage context score."""
|
||||
return 0.50
|
||||
|
||||
|
||||
def _calculate_rest_travel_score(player_profile, game_context):
|
||||
"""Calculate rest/travel fatigue score."""
|
||||
return 0.0
|
||||
@@ -0,0 +1,712 @@
|
||||
"""
|
||||
VYNDR Odds Scanner — Blueprint
|
||||
Fetches, parses, stores, and analyzes odds from The Odds API.
|
||||
Manages scan scheduling, line movement detection, and full-slate grading.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import (
|
||||
store_odds_batch,
|
||||
fetch_odds_by_date,
|
||||
fetch_odds_by_scan_type,
|
||||
)
|
||||
from utils.retry import retry_with_backoff
|
||||
from utils.edge_calculator import calculate_real_edge, grade_edge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
odds_bp = Blueprint('odds_scanner', __name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports'
|
||||
ODDS_API_KEY = os.environ.get('ODDS_API_KEY')
|
||||
|
||||
SPORT_KEYS = {
|
||||
'nba': 'basketball_nba',
|
||||
'mlb': 'baseball_mlb',
|
||||
}
|
||||
|
||||
# Free-tier scan strategy — maximizes coverage on 2 pulls/day
|
||||
ODDS_SCAN_STRATEGY = {
|
||||
'morning_scan': '10:00 AM ET',
|
||||
'pre_game_scan': '90min before first game',
|
||||
'max_daily_pulls': 2,
|
||||
'priority': 'games_with_confirmed_lineups_first',
|
||||
'market_priority': [
|
||||
'player_points',
|
||||
'player_rebounds',
|
||||
'player_assists',
|
||||
'player_threes',
|
||||
'player_points_rebounds_assists',
|
||||
'player_strikeouts',
|
||||
'player_hits',
|
||||
'player_total_bases',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@retry_with_backoff(max_retries=3, base_delay=2.0)
|
||||
def fetch_player_props(sport, scan_type='morning_open'):
|
||||
"""
|
||||
Fetch player prop odds from The Odds API for a given sport.
|
||||
|
||||
Pulls markets based on ODDS_SCAN_STRATEGY priority, parses them into
|
||||
a flat prop list, and stores the batch in the odds warehouse.
|
||||
|
||||
Args:
|
||||
sport: Sport key (e.g. 'nba', 'mlb').
|
||||
scan_type: One of 'morning_open' or 'pre_game'.
|
||||
|
||||
Returns:
|
||||
dict with 'props_stored' count and 'api_requests_remaining'.
|
||||
|
||||
Raises:
|
||||
ValueError: If sport is not supported or API key is missing.
|
||||
requests.exceptions.RequestException: On network failures (retried).
|
||||
"""
|
||||
if not ODDS_API_KEY:
|
||||
raise ValueError('ODDS_API_KEY environment variable is not set')
|
||||
|
||||
sport_key = SPORT_KEYS.get(sport)
|
||||
if not sport_key:
|
||||
raise ValueError(f'Unsupported sport: {sport}. Supported: {list(SPORT_KEYS.keys())}')
|
||||
|
||||
markets = ','.join(ODDS_SCAN_STRATEGY['market_priority'])
|
||||
|
||||
url = f'{ODDS_API_BASE}/{sport_key}/odds'
|
||||
params = {
|
||||
'apiKey': ODDS_API_KEY,
|
||||
'regions': 'us',
|
||||
'markets': markets,
|
||||
'oddsFormat': 'american',
|
||||
}
|
||||
|
||||
logger.info('Fetching props for %s (scan_type=%s)', sport, scan_type)
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
api_requests_remaining = response.headers.get('x-requests-remaining', 'unknown')
|
||||
logger.info('API requests remaining: %s', api_requests_remaining)
|
||||
|
||||
raw_games = response.json()
|
||||
props = parse_odds_response(raw_games, sport)
|
||||
|
||||
stored_count = store_in_odds_warehouse(props, sport, scan_type)
|
||||
|
||||
return {
|
||||
'props_stored': stored_count,
|
||||
'api_requests_remaining': api_requests_remaining,
|
||||
}
|
||||
|
||||
|
||||
def parse_odds_response(response, sport):
|
||||
"""
|
||||
Parse raw Odds API response into a flat list of prop dicts.
|
||||
|
||||
Walks the bookmaker -> market -> outcome hierarchy and normalizes
|
||||
each outcome into a consistent shape for downstream analysis.
|
||||
|
||||
Args:
|
||||
response: List of game objects from The Odds API.
|
||||
sport: Sport key for tagging.
|
||||
|
||||
Returns:
|
||||
List of dicts, each representing a single prop line:
|
||||
{
|
||||
'game_id', 'home_team', 'away_team', 'commence_time',
|
||||
'bookmaker', 'market', 'player', 'line', 'over_price',
|
||||
'under_price', 'sport', 'fetched_at'
|
||||
}
|
||||
"""
|
||||
props = []
|
||||
fetched_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
for game in response:
|
||||
game_id = game.get('id')
|
||||
home_team = game.get('home_team')
|
||||
away_team = game.get('away_team')
|
||||
commence_time = game.get('commence_time')
|
||||
|
||||
for bookmaker in game.get('bookmakers', []):
|
||||
bookmaker_key = bookmaker.get('key')
|
||||
|
||||
for market in bookmaker.get('markets', []):
|
||||
market_key = market.get('key')
|
||||
outcomes = market.get('outcomes', [])
|
||||
|
||||
# Outcomes come in Over/Under pairs — group by player + line
|
||||
outcome_map = {}
|
||||
for outcome in outcomes:
|
||||
player = outcome.get('description', 'unknown')
|
||||
point = outcome.get('point')
|
||||
side = outcome.get('name', '').lower() # 'over' or 'under'
|
||||
price = outcome.get('price')
|
||||
|
||||
key = (player, point)
|
||||
if key not in outcome_map:
|
||||
outcome_map[key] = {
|
||||
'player': player,
|
||||
'line': point,
|
||||
'over_price': None,
|
||||
'under_price': None,
|
||||
}
|
||||
|
||||
if side == 'over':
|
||||
outcome_map[key]['over_price'] = price
|
||||
elif side == 'under':
|
||||
outcome_map[key]['under_price'] = price
|
||||
|
||||
for (player, line), data in outcome_map.items():
|
||||
props.append({
|
||||
'game_id': game_id,
|
||||
'home_team': home_team,
|
||||
'away_team': away_team,
|
||||
'commence_time': commence_time,
|
||||
'bookmaker': bookmaker_key,
|
||||
'market': market_key,
|
||||
'player': data['player'],
|
||||
'line': data['line'],
|
||||
'over_price': data['over_price'],
|
||||
'under_price': data['under_price'],
|
||||
'sport': sport,
|
||||
'fetched_at': fetched_at,
|
||||
})
|
||||
|
||||
logger.info('Parsed %d props from %d games', len(props), len(response))
|
||||
return props
|
||||
|
||||
|
||||
def store_in_odds_warehouse(props, sport, scan_type):
|
||||
"""
|
||||
Persist parsed props to the Supabase odds_warehouse table.
|
||||
|
||||
Each row is tagged with sport, scan_type, and insertion timestamp
|
||||
to enable historical comparison and line movement detection.
|
||||
|
||||
Args:
|
||||
props: List of prop dicts from parse_odds_response.
|
||||
sport: Sport key.
|
||||
scan_type: 'morning_open' or 'pre_game'.
|
||||
|
||||
Returns:
|
||||
int — number of rows stored.
|
||||
"""
|
||||
if not props:
|
||||
logger.warning('No props to store for %s (%s)', sport, scan_type)
|
||||
return 0
|
||||
|
||||
rows = []
|
||||
for prop in props:
|
||||
rows.append({
|
||||
**prop,
|
||||
'scan_type': scan_type,
|
||||
'stored_at': datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
try:
|
||||
result = store_odds_batch(rows)
|
||||
stored = result.get('count', len(rows))
|
||||
logger.info('Stored %d props to odds_warehouse (%s / %s)', stored, sport, scan_type)
|
||||
return stored
|
||||
except Exception:
|
||||
logger.exception('Failed to store props for %s (%s)', sport, scan_type)
|
||||
raise
|
||||
|
||||
|
||||
def detect_line_movements(sport, threshold=0.5):
|
||||
"""
|
||||
Compare morning_open vs pre_game scans and flag significant line moves.
|
||||
|
||||
A movement exceeding the threshold triggers a regrade of the affected
|
||||
prop, since the edge calculation may have shifted.
|
||||
|
||||
Args:
|
||||
sport: Sport key.
|
||||
threshold: Minimum absolute line change to flag (default 0.5).
|
||||
|
||||
Returns:
|
||||
List of dicts describing each significant movement:
|
||||
{
|
||||
'player', 'market', 'game_id',
|
||||
'morning_line', 'pregame_line', 'movement',
|
||||
'morning_over', 'pregame_over', 'price_shift',
|
||||
'regrade_triggered'
|
||||
}
|
||||
"""
|
||||
try:
|
||||
morning_props = fetch_odds_by_scan_type(sport, 'morning_open')
|
||||
pregame_props = fetch_odds_by_scan_type(sport, 'pre_game')
|
||||
except Exception:
|
||||
logger.exception('Failed to fetch scans for movement detection (%s)', sport)
|
||||
raise
|
||||
|
||||
# Index morning props by (player, market, game_id) for fast lookup
|
||||
morning_index = {}
|
||||
for prop in morning_props:
|
||||
key = (prop['player'], prop['market'], prop['game_id'])
|
||||
morning_index[key] = prop
|
||||
|
||||
movements = []
|
||||
for prop in pregame_props:
|
||||
key = (prop['player'], prop['market'], prop['game_id'])
|
||||
morning = morning_index.get(key)
|
||||
if not morning:
|
||||
continue
|
||||
|
||||
morning_line = morning.get('line') or 0
|
||||
pregame_line = prop.get('line') or 0
|
||||
line_movement = abs(pregame_line - morning_line)
|
||||
|
||||
morning_over = morning.get('over_price') or 0
|
||||
pregame_over = prop.get('over_price') or 0
|
||||
price_shift = pregame_over - morning_over
|
||||
|
||||
if line_movement >= threshold:
|
||||
regrade_triggered = True
|
||||
logger.info(
|
||||
'Line movement detected: %s %s — %.1f -> %.1f (delta %.1f)',
|
||||
prop['player'], prop['market'], morning_line, pregame_line, line_movement,
|
||||
)
|
||||
else:
|
||||
regrade_triggered = False
|
||||
|
||||
if line_movement >= threshold or abs(price_shift) >= 15:
|
||||
movements.append({
|
||||
'player': prop['player'],
|
||||
'market': prop['market'],
|
||||
'game_id': prop['game_id'],
|
||||
'morning_line': morning_line,
|
||||
'pregame_line': pregame_line,
|
||||
'movement': round(pregame_line - morning_line, 2),
|
||||
'morning_over': morning_over,
|
||||
'pregame_over': pregame_over,
|
||||
'price_shift': price_shift,
|
||||
'regrade_triggered': regrade_triggered,
|
||||
})
|
||||
|
||||
logger.info('Detected %d significant movements for %s', len(movements), sport)
|
||||
return movements
|
||||
|
||||
|
||||
def scan_full_slate(sport):
|
||||
"""
|
||||
Grade every prop on tonight's slate and return ranked results.
|
||||
|
||||
Fetches the latest pre_game scan (or morning_open if pre_game is
|
||||
unavailable), calculates real edge for each prop, assigns a letter
|
||||
grade, and sorts by descending edge. The capper account only posts
|
||||
plays graded A- and above.
|
||||
|
||||
Args:
|
||||
sport: Sport key.
|
||||
|
||||
Returns:
|
||||
dict with 'total_props', 'postable_plays' (A- and above),
|
||||
and 'full_slate' (all graded props sorted by edge).
|
||||
"""
|
||||
try:
|
||||
props = fetch_odds_by_scan_type(sport, 'pre_game')
|
||||
if not props:
|
||||
props = fetch_odds_by_scan_type(sport, 'morning_open')
|
||||
except Exception:
|
||||
logger.exception('Failed to fetch props for full slate scan (%s)', sport)
|
||||
raise
|
||||
|
||||
if not props:
|
||||
return {'total_props': 0, 'postable_plays': [], 'full_slate': []}
|
||||
|
||||
graded = []
|
||||
for prop in props:
|
||||
try:
|
||||
edge_result = calculate_real_edge(prop)
|
||||
real_edge = edge_result.get('edge', 0)
|
||||
grade = grade_edge(real_edge)
|
||||
|
||||
graded.append({
|
||||
'player': prop.get('player'),
|
||||
'market': prop.get('market'),
|
||||
'game_id': prop.get('game_id'),
|
||||
'home_team': prop.get('home_team'),
|
||||
'away_team': prop.get('away_team'),
|
||||
'line': prop.get('line'),
|
||||
'over_price': prop.get('over_price'),
|
||||
'under_price': prop.get('under_price'),
|
||||
'bookmaker': prop.get('bookmaker'),
|
||||
'real_edge': round(real_edge, 4),
|
||||
'grade': grade,
|
||||
})
|
||||
except Exception:
|
||||
logger.warning('Failed to grade prop: %s %s', prop.get('player'), prop.get('market'))
|
||||
continue
|
||||
|
||||
# Sort by real edge descending
|
||||
graded.sort(key=lambda p: p['real_edge'], reverse=True)
|
||||
|
||||
# Capper account only posts A- and above
|
||||
postable_grades = {'A+', 'A', 'A-'}
|
||||
postable = [p for p in graded if p['grade'] in postable_grades]
|
||||
|
||||
logger.info(
|
||||
'Slate scan complete for %s: %d total, %d postable',
|
||||
sport, len(graded), len(postable),
|
||||
)
|
||||
|
||||
return {
|
||||
'total_props': len(graded),
|
||||
'postable_plays': postable,
|
||||
'full_slate': graded,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@odds_bp.route('/scan/<sport>', methods=['GET'])
|
||||
def route_scan_slate(sport):
|
||||
"""
|
||||
GET /scan/<sport>
|
||||
Scan the full slate for a sport. Returns graded props ranked by edge.
|
||||
Query params:
|
||||
fetch (bool): If true, fetch fresh odds before scanning. Default false.
|
||||
"""
|
||||
try:
|
||||
if sport not in SPORT_KEYS:
|
||||
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
|
||||
|
||||
fetch_fresh = request.args.get('fetch', 'false').lower() == 'true'
|
||||
scan_type = request.args.get('scan_type', 'morning_open')
|
||||
|
||||
if fetch_fresh:
|
||||
fetch_result = fetch_player_props(sport, scan_type=scan_type)
|
||||
logger.info('Fresh fetch completed: %s', fetch_result)
|
||||
|
||||
result = scan_full_slate(sport)
|
||||
return jsonify(result), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.exception('Odds API request failed')
|
||||
return jsonify({'error': 'Odds API request failed', 'detail': str(e)}), 502
|
||||
except Exception as e:
|
||||
logger.exception('Unexpected error in scan route')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@odds_bp.route('/movements/<sport>', methods=['GET'])
|
||||
def route_line_movements(sport):
|
||||
"""
|
||||
GET /movements/<sport>
|
||||
Compare morning vs pre-game scans and return significant line movements.
|
||||
Query params:
|
||||
threshold (float): Minimum line change to flag. Default 0.5.
|
||||
"""
|
||||
try:
|
||||
if sport not in SPORT_KEYS:
|
||||
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
|
||||
|
||||
threshold = float(request.args.get('threshold', 0.5))
|
||||
movements = detect_line_movements(sport, threshold=threshold)
|
||||
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'threshold': threshold,
|
||||
'count': len(movements),
|
||||
'movements': movements,
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.exception('Unexpected error in movements route')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@odds_bp.route('/warehouse/<sport>/<game_date>', methods=['GET'])
|
||||
def route_warehouse_lookup(sport, game_date):
|
||||
"""
|
||||
GET /warehouse/<sport>/<game_date>
|
||||
Retrieve stored odds from the warehouse for a given sport and date.
|
||||
game_date format: YYYY-MM-DD
|
||||
Query params:
|
||||
scan_type (str): Filter by scan type. Optional.
|
||||
market (str): Filter by market key. Optional.
|
||||
"""
|
||||
try:
|
||||
if sport not in SPORT_KEYS:
|
||||
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
|
||||
|
||||
# Validate date format
|
||||
try:
|
||||
datetime.strptime(game_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD.'}), 400
|
||||
|
||||
scan_type = request.args.get('scan_type')
|
||||
market = request.args.get('market')
|
||||
|
||||
props = fetch_odds_by_date(sport, game_date)
|
||||
|
||||
# Apply optional filters
|
||||
if scan_type:
|
||||
props = [p for p in props if p.get('scan_type') == scan_type]
|
||||
if market:
|
||||
props = [p for p in props if p.get('market') == market]
|
||||
|
||||
return jsonify({
|
||||
'sport': sport,
|
||||
'game_date': game_date,
|
||||
'count': len(props),
|
||||
'props': props,
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Unexpected error in warehouse route')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SUPPLEMENT: Alt Line Scanner
|
||||
# ============================================================
|
||||
|
||||
ALT_LINE_EDGE_IMPROVEMENT_THRESHOLD = 0.03 # 3% edge improvement minimum
|
||||
ALT_LINE_MODE = os.environ.get('ALT_LINE_MODE', 'manual')
|
||||
# 'api' = pull from Odds API (requires paid tier with alt markets)
|
||||
# 'manual' = generate probability ladder at common alt line intervals
|
||||
|
||||
|
||||
def scan_alt_lines_internal(sport, player_name, stat_type, standard_grade=None):
|
||||
"""
|
||||
Scan alt lines for a single prop. Finds the alt line with the best
|
||||
edge-to-odds ratio. Only recommends if edge exceeds standard by 3%+.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
player_name: Player name string.
|
||||
stat_type: Stat type string.
|
||||
standard_grade: Optional pre-fetched grade result dict.
|
||||
|
||||
Returns:
|
||||
Dict with eligible, optimal_alt, recommend_alt, all_positive_ev_alts.
|
||||
"""
|
||||
if not standard_grade:
|
||||
return {'eligible': False, 'reason': 'No standard grade provided'}
|
||||
|
||||
if standard_grade.get('grade') not in ['A+', 'A', 'A-']:
|
||||
return {'eligible': False, 'reason': 'Only runs on A-grade props'}
|
||||
|
||||
model_projection = standard_grade.get('projected_value', 0)
|
||||
model_std = standard_grade.get('projected_std', 1)
|
||||
standard_edge = standard_grade.get('real_edge', {}).get('real_edge', 0)
|
||||
|
||||
# Get alt lines from odds warehouse
|
||||
alt_lines = _get_alt_lines_from_warehouse(player_name, stat_type, sport)
|
||||
if not alt_lines:
|
||||
return {'eligible': True, 'alt_lines': [], 'reason': 'No alt lines available'}
|
||||
|
||||
from utils.bayesian import norm_cdf
|
||||
from utils.edge_calculator import calculate_real_edge, kelly_criterion
|
||||
|
||||
scored_alts = []
|
||||
for alt in alt_lines:
|
||||
alt_line = alt.get('line')
|
||||
alt_odds = alt.get('price', -110)
|
||||
over_under = alt.get('over_under', 'over')
|
||||
|
||||
if alt_line is None or alt_odds is None:
|
||||
continue
|
||||
|
||||
# Calculate model probability at this alt line
|
||||
if over_under == 'over':
|
||||
model_prob = 1 - norm_cdf(alt_line, model_projection, model_std)
|
||||
else:
|
||||
model_prob = norm_cdf(alt_line, model_projection, model_std)
|
||||
|
||||
# Calculate real edge with vig
|
||||
edge = calculate_real_edge(model_prob, alt_odds)
|
||||
kelly = kelly_criterion(model_prob, alt_odds)
|
||||
|
||||
if edge['is_positive_ev']:
|
||||
scored_alts.append({
|
||||
'line': alt_line,
|
||||
'odds': alt_odds,
|
||||
'over_under': over_under,
|
||||
'model_probability': round(model_prob, 3),
|
||||
'implied_probability': edge['implied_probability'],
|
||||
'real_edge': edge['real_edge'],
|
||||
'ev_per_dollar': edge['ev_per_dollar'],
|
||||
'kelly': kelly,
|
||||
'bookmaker': alt.get('bookmaker'),
|
||||
'edge_vs_standard': round(edge['real_edge'] - standard_edge, 3)
|
||||
})
|
||||
|
||||
scored_alts.sort(key=lambda x: x['ev_per_dollar'], reverse=True)
|
||||
|
||||
optimal = scored_alts[0] if scored_alts else None
|
||||
recommend = (optimal is not None and
|
||||
optimal['edge_vs_standard'] >= ALT_LINE_EDGE_IMPROVEMENT_THRESHOLD)
|
||||
|
||||
return {
|
||||
'eligible': True,
|
||||
'player': player_name,
|
||||
'stat_type': stat_type,
|
||||
'standard_grade': standard_grade.get('grade'),
|
||||
'standard_edge': standard_edge,
|
||||
'alt_lines_found': len(scored_alts),
|
||||
'optimal_alt': optimal,
|
||||
'recommend_alt': recommend,
|
||||
'all_positive_ev_alts': scored_alts[:5]
|
||||
}
|
||||
|
||||
|
||||
def auto_scan_alt_lines_for_a_grades(sport, a_grades=None):
|
||||
"""
|
||||
Called after slate scan. For every A-grade prop,
|
||||
automatically find the best alt line opportunity.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
a_grades: Optional list of A-grade result dicts.
|
||||
|
||||
Returns:
|
||||
List of alt line opportunity dicts where alt is recommended.
|
||||
"""
|
||||
if not a_grades:
|
||||
return []
|
||||
|
||||
alt_opportunities = []
|
||||
for grade in a_grades:
|
||||
result = scan_alt_lines_internal(
|
||||
sport,
|
||||
grade.get('player_name', ''),
|
||||
grade.get('stat_type', ''),
|
||||
standard_grade=grade
|
||||
)
|
||||
if result.get('recommend_alt') and result.get('optimal_alt'):
|
||||
alt_opportunities.append({
|
||||
'player': grade.get('player_name'),
|
||||
'standard': {
|
||||
'line': grade.get('line'),
|
||||
'grade': grade.get('grade'),
|
||||
'edge': grade.get('real_edge', {}).get('real_edge')
|
||||
},
|
||||
'alt': result['optimal_alt'],
|
||||
'edge_improvement': result['optimal_alt']['edge_vs_standard']
|
||||
})
|
||||
|
||||
return alt_opportunities
|
||||
|
||||
|
||||
def _get_alt_lines_from_warehouse(player_name, stat_type, sport):
|
||||
"""Stub: fetch alt lines from odds_warehouse table."""
|
||||
return []
|
||||
|
||||
|
||||
@odds_bp.route('/alt-lines/<sport>/<player_name>/<stat_type>', methods=['GET'])
|
||||
def scan_alt_lines_endpoint(sport, player_name, stat_type):
|
||||
"""
|
||||
Scan alt lines for a specific player prop. Auto-runs on A-grade props.
|
||||
Finds the alt line with the best edge-to-odds ratio.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
player_name: Player name.
|
||||
stat_type: Stat type.
|
||||
|
||||
Returns:
|
||||
JSON with eligible, optimal_alt, recommend_alt, positive EV alts.
|
||||
"""
|
||||
result = scan_alt_lines_internal(sport, player_name, stat_type)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH Item 10: Alt line ladder mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_alt_line_ladder(player_name, stat_type, sport, standard_grade=None):
|
||||
"""
|
||||
When alt lines aren't available from API, generate a probability ladder
|
||||
showing model probability at each half-point from the standard line.
|
||||
User can then manually check their book for pricing.
|
||||
|
||||
Args:
|
||||
player_name: Player name.
|
||||
stat_type: Stat type.
|
||||
sport: 'nba' or 'mlb'.
|
||||
standard_grade: Optional pre-fetched grade result.
|
||||
|
||||
Returns:
|
||||
Dict with ladder of probabilities at common alt line offsets.
|
||||
"""
|
||||
if not standard_grade:
|
||||
return {'eligible': False, 'reason': 'No standard grade'}
|
||||
|
||||
from utils.bayesian import norm_cdf
|
||||
|
||||
mean = standard_grade.get('projected_value', 0)
|
||||
std = standard_grade.get('projected_std', 1)
|
||||
base_line = standard_grade.get('line', 0)
|
||||
|
||||
if std <= 0:
|
||||
return {'eligible': False, 'reason': 'Invalid projection std'}
|
||||
|
||||
ladder = []
|
||||
for offset in [1, 1.5, 2, 2.5, 3, 4, 5]:
|
||||
over_line = base_line + offset
|
||||
under_line = base_line - offset
|
||||
prob_over = round(1 - norm_cdf(over_line, mean, std), 3)
|
||||
prob_under = round(norm_cdf(under_line, mean, std), 3)
|
||||
|
||||
ladder.append({
|
||||
'over_line': over_line,
|
||||
'over_probability': prob_over,
|
||||
'under_line': under_line,
|
||||
'under_probability': prob_under,
|
||||
'offset': offset
|
||||
})
|
||||
|
||||
return {
|
||||
'eligible': True,
|
||||
'mode': 'ladder',
|
||||
'standard_line': base_line,
|
||||
'projection': mean,
|
||||
'ladder': ladder,
|
||||
'note': 'Compare these probabilities to your book alt line pricing to find edge'
|
||||
}
|
||||
|
||||
|
||||
def fetch_and_store_odds(sport, scan_type):
|
||||
"""
|
||||
Fetch odds from API and store in warehouse. Called by GitHub Actions crons.
|
||||
|
||||
Args:
|
||||
sport: 'nba' or 'mlb'.
|
||||
scan_type: 'morning_open' or 'pre_game'.
|
||||
"""
|
||||
logger.info(f'[VYNDR] Fetching {scan_type} odds for {sport}')
|
||||
# In production: calls fetch_player_props and stores result
|
||||
|
||||
|
||||
def check_all_games_weather_regrade():
|
||||
"""
|
||||
Check weather for all today's games and trigger regrade if needed.
|
||||
Called by weather monitoring cron.
|
||||
"""
|
||||
from utils.weather import check_weather_for_regrade
|
||||
logger.info('[VYNDR] Checking weather for all games')
|
||||
# In production: iterate today's MLB games, call check_weather_for_regrade
|
||||
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
VYNDR Usage Redistribution Engine — Blueprint
|
||||
Calculates how a player's usage, minutes, and role redistribute across
|
||||
teammates when a key player is ruled OUT. Layers minutes redistribution
|
||||
on top of archetype-driven system-change modifiers, applies efficiency
|
||||
tradeoffs, and surfaces auto-grade targets for the scanner.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.retry import api_call_with_retry
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
redistribution_bp = Blueprint('redistribution', __name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-change archetype maps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_SHIFT_MAP = {
|
||||
'primary_scorer': {
|
||||
'secondary_creator': 0.08,
|
||||
'primary_playmaker': 0.03,
|
||||
'three_and_d': 0.04,
|
||||
},
|
||||
'primary_playmaker': {
|
||||
'primary_scorer': 0.05,
|
||||
'secondary_creator': 0.06,
|
||||
'three_and_d': -0.02,
|
||||
},
|
||||
'interior_big': {
|
||||
'stretch_big': 0.07,
|
||||
'primary_scorer': 0.03,
|
||||
},
|
||||
}
|
||||
|
||||
# Usage-efficiency tradeoff slope: each +5 pct of raw usage boost
|
||||
# carries a -1.5 pct efficiency drag.
|
||||
USAGE_EFFICIENCY_PENALTY_PER_UNIT = -0.015 / 0.05 # -0.30 per 1.0
|
||||
|
||||
# Absorption tier thresholds
|
||||
TIER_PRIMARY = {'min_boost': 0.20, 'min_confidence': 0.75}
|
||||
TIER_SECONDARY = {'min_boost': 0.10, 'min_confidence': 0.60}
|
||||
TIER_TERTIARY = {'min_boost': 0.05, 'min_confidence': 0.0}
|
||||
|
||||
# Auto-grade qualifying thresholds
|
||||
AUTO_GRADE_MIN_BOOST = 0.15
|
||||
AUTO_GRADE_MIN_CONFIDENCE = 0.65
|
||||
|
||||
# Minimum historical player-out events for data-driven redistribution
|
||||
MIN_HISTORICAL_EVENTS = 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper stubs — backed by Supabase / external APIs via data_warehouse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_player_profile(player_id):
|
||||
"""
|
||||
Retrieve a player's profile including archetype, position, and
|
||||
season usage rate from the data warehouse.
|
||||
|
||||
Args:
|
||||
player_id: Unique player identifier.
|
||||
|
||||
Returns:
|
||||
Dict with keys: player_id, name, position, archetype, usage_rate,
|
||||
minutes_per_game, team_id. None if not found.
|
||||
"""
|
||||
cache_key = f'player_profile:{player_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(
|
||||
_fetch_player_profile_from_db, player_id
|
||||
),
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_player_profile_from_db(player_id):
|
||||
"""Raw DB fetch for player profile. Stub — replace with Supabase query."""
|
||||
logger.warning('get_player_profile stub called for %s', player_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_game_context(game_id):
|
||||
"""
|
||||
Retrieve game context: teams, schedule, venue, pace environment.
|
||||
|
||||
Args:
|
||||
game_id: Unique game identifier.
|
||||
|
||||
Returns:
|
||||
Dict with keys: game_id, home_team_id, away_team_id, venue, pace.
|
||||
"""
|
||||
cache_key = f'game_context:{game_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(_fetch_game_context_from_db, game_id),
|
||||
ttl_hours=6,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_game_context_from_db(game_id):
|
||||
"""Raw DB fetch for game context. Stub — replace with Supabase query."""
|
||||
logger.warning('get_game_context stub called for %s', game_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_team_coach(team_id):
|
||||
"""
|
||||
Look up the head coach for a team.
|
||||
|
||||
Args:
|
||||
team_id: Team identifier.
|
||||
|
||||
Returns:
|
||||
Dict with keys: coach_id, name, team_id.
|
||||
"""
|
||||
cache_key = f'team_coach:{team_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(_fetch_team_coach, team_id),
|
||||
ttl_hours=168,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_team_coach(team_id):
|
||||
"""Stub — replace with Supabase query."""
|
||||
logger.warning('get_team_coach stub called for %s', team_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_coaching_tendencies(coach_id):
|
||||
"""
|
||||
Retrieve coaching tendency profile: rotation depth, archetype preferences,
|
||||
and any redistribution_profile overrides.
|
||||
|
||||
Args:
|
||||
coach_id: Unique coach identifier.
|
||||
|
||||
Returns:
|
||||
Dict with keys: coach_id, rotation_depth (int), style,
|
||||
redistribution_profile (dict or None).
|
||||
"""
|
||||
cache_key = f'coaching_tendencies:{coach_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(_fetch_coaching_tendencies, coach_id),
|
||||
ttl_hours=168,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_coaching_tendencies(coach_id):
|
||||
"""Stub — replace with Supabase query."""
|
||||
logger.warning('get_coaching_tendencies stub called for %s', coach_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_available_roster(team_id, game_id):
|
||||
"""
|
||||
Get the roster of available (non-injured, non-out) players for a
|
||||
specific game.
|
||||
|
||||
Args:
|
||||
team_id: Team identifier.
|
||||
game_id: Game identifier.
|
||||
|
||||
Returns:
|
||||
List of player profile dicts (same shape as get_player_profile).
|
||||
"""
|
||||
cache_key = f'available_roster:{team_id}:{game_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(
|
||||
_fetch_available_roster, team_id, game_id
|
||||
),
|
||||
ttl_hours=1,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_available_roster(team_id, game_id):
|
||||
"""Stub — replace with lineup service integration."""
|
||||
logger.warning(
|
||||
'get_available_roster stub called for team=%s game=%s',
|
||||
team_id,
|
||||
game_id,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def get_player_out_history(player_id):
|
||||
"""
|
||||
Retrieve historical instances where this player was ruled OUT,
|
||||
including how minutes and usage redistributed in those games.
|
||||
|
||||
Args:
|
||||
player_id: Unique player identifier.
|
||||
|
||||
Returns:
|
||||
List of dicts, each with keys: game_id, date, teammate_impacts
|
||||
(list of {player_id, minutes_gained, usage_gained}).
|
||||
"""
|
||||
cache_key = f'player_out_history:{player_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(
|
||||
_fetch_player_out_history, player_id
|
||||
),
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_player_out_history(player_id):
|
||||
"""Stub — replace with Supabase query on historical game logs."""
|
||||
logger.warning('get_player_out_history stub called for %s', player_id)
|
||||
return []
|
||||
|
||||
|
||||
def get_team_roster(team_id):
|
||||
"""
|
||||
Get the full active roster for a team (not filtered by game availability).
|
||||
|
||||
Args:
|
||||
team_id: Team identifier.
|
||||
|
||||
Returns:
|
||||
List of player profile dicts.
|
||||
"""
|
||||
cache_key = f'team_roster:{team_id}'
|
||||
return fetch_with_cache(
|
||||
cache_key,
|
||||
lambda: api_call_with_retry(_fetch_team_roster, team_id),
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_team_roster(team_id):
|
||||
"""Stub — replace with Supabase query."""
|
||||
logger.warning('get_team_roster stub called for %s', team_id)
|
||||
return []
|
||||
|
||||
|
||||
def aggregate_historical_minutes(history):
|
||||
"""
|
||||
Aggregate historical player-out events into average per-teammate
|
||||
minutes and usage gains.
|
||||
|
||||
Args:
|
||||
history: List of historical event dicts from get_player_out_history.
|
||||
|
||||
Returns:
|
||||
Dict mapping teammate player_id to {avg_minutes_gained,
|
||||
avg_usage_gained, sample_size}.
|
||||
"""
|
||||
if not history:
|
||||
return {}
|
||||
|
||||
teammate_totals = {}
|
||||
|
||||
for event in history:
|
||||
for impact in event.get('teammate_impacts', []):
|
||||
pid = impact.get('player_id')
|
||||
if pid is None:
|
||||
continue
|
||||
if pid not in teammate_totals:
|
||||
teammate_totals[pid] = {
|
||||
'total_minutes': 0.0,
|
||||
'total_usage': 0.0,
|
||||
'count': 0,
|
||||
}
|
||||
teammate_totals[pid]['total_minutes'] += impact.get(
|
||||
'minutes_gained', 0.0
|
||||
)
|
||||
teammate_totals[pid]['total_usage'] += impact.get(
|
||||
'usage_gained', 0.0
|
||||
)
|
||||
teammate_totals[pid]['count'] += 1
|
||||
|
||||
aggregated = {}
|
||||
for pid, totals in teammate_totals.items():
|
||||
n = totals['count']
|
||||
aggregated[pid] = {
|
||||
'avg_minutes_gained': round(totals['total_minutes'] / n, 2),
|
||||
'avg_usage_gained': round(totals['total_usage'] / n, 4),
|
||||
'sample_size': n,
|
||||
}
|
||||
|
||||
return aggregated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core calculation layers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def calculate_minutes_redistribution(
|
||||
player_out, game_context, coaching, available_roster
|
||||
):
|
||||
"""
|
||||
Layer A: Determine how the absent player's minutes redistribute.
|
||||
|
||||
Strategy:
|
||||
1. If 5+ historical player-out events exist, use empirical data.
|
||||
2. Otherwise, fall back to positional fit + coaching rotation depth.
|
||||
- Concentrated coach (rotation_depth <= 7): backup gets 70%,
|
||||
remaining positional matches split 10% each.
|
||||
- Distributed coach (rotation_depth > 7): spread across 3-4
|
||||
players roughly evenly.
|
||||
|
||||
Args:
|
||||
player_out: Player profile dict of the absent player.
|
||||
game_context: Game context dict.
|
||||
coaching: Coaching tendencies dict.
|
||||
available_roster: List of available teammate profile dicts.
|
||||
|
||||
Returns:
|
||||
List of dicts: [{player_id, name, minutes_share, source}]
|
||||
sorted descending by minutes_share.
|
||||
"""
|
||||
player_out_id = player_out.get('player_id')
|
||||
history = get_player_out_history(player_out_id)
|
||||
|
||||
# --- Path 1: Historical data-driven ---
|
||||
if len(history) >= MIN_HISTORICAL_EVENTS:
|
||||
logger.info(
|
||||
'Using historical redistribution for player %s (%d events)',
|
||||
player_out_id,
|
||||
len(history),
|
||||
)
|
||||
aggregated = aggregate_historical_minutes(history)
|
||||
available_ids = {p.get('player_id') for p in available_roster}
|
||||
|
||||
results = []
|
||||
for pid, stats in aggregated.items():
|
||||
if pid not in available_ids:
|
||||
continue
|
||||
teammate = next(
|
||||
(p for p in available_roster if p.get('player_id') == pid),
|
||||
None,
|
||||
)
|
||||
if teammate is None:
|
||||
continue
|
||||
results.append({
|
||||
'player_id': pid,
|
||||
'name': teammate.get('name', 'Unknown'),
|
||||
'minutes_share': stats['avg_minutes_gained'],
|
||||
'source': 'historical',
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['minutes_share'], reverse=True)
|
||||
return results
|
||||
|
||||
# --- Path 2: Positional + coaching fallback ---
|
||||
logger.info(
|
||||
'Using positional/coaching fallback for player %s', player_out_id
|
||||
)
|
||||
position = player_out.get('position', 'G')
|
||||
rotation_depth = coaching.get('rotation_depth', 8) if coaching else 8
|
||||
minutes_to_distribute = player_out.get('minutes_per_game', 32.0)
|
||||
|
||||
# Find positional matches
|
||||
positional_matches = [
|
||||
p
|
||||
for p in available_roster
|
||||
if p.get('position') == position
|
||||
and p.get('player_id') != player_out_id
|
||||
]
|
||||
other_roster = [
|
||||
p
|
||||
for p in available_roster
|
||||
if p.get('position') != position
|
||||
and p.get('player_id') != player_out_id
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
if rotation_depth <= 7:
|
||||
# Concentrated coach — backup gets 70%, others share 10% each
|
||||
if positional_matches:
|
||||
backup = positional_matches[0]
|
||||
results.append({
|
||||
'player_id': backup.get('player_id'),
|
||||
'name': backup.get('name', 'Unknown'),
|
||||
'minutes_share': round(minutes_to_distribute * 0.70, 1),
|
||||
'source': 'positional_concentrated',
|
||||
})
|
||||
remaining = minutes_to_distribute * 0.30
|
||||
fill_players = positional_matches[1:] + other_roster
|
||||
per_player = (
|
||||
round(minutes_to_distribute * 0.10, 1)
|
||||
if fill_players
|
||||
else 0.0
|
||||
)
|
||||
for p in fill_players[:3]:
|
||||
results.append({
|
||||
'player_id': p.get('player_id'),
|
||||
'name': p.get('name', 'Unknown'),
|
||||
'minutes_share': per_player,
|
||||
'source': 'positional_concentrated',
|
||||
})
|
||||
else:
|
||||
# Distributed coach — spread across 3-4 players
|
||||
spread_players = (positional_matches + other_roster)[:4]
|
||||
if spread_players:
|
||||
share = round(minutes_to_distribute / len(spread_players), 1)
|
||||
for p in spread_players:
|
||||
results.append({
|
||||
'player_id': p.get('player_id'),
|
||||
'name': p.get('name', 'Unknown'),
|
||||
'minutes_share': share,
|
||||
'source': 'positional_distributed',
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['minutes_share'], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def calculate_system_change(player_out, coaching, available_roster):
|
||||
"""
|
||||
Layer B: Determine archetype-driven usage shifts when a player is OUT.
|
||||
|
||||
Maps the absent player's archetype to a system-shift dict, then applies
|
||||
coach-specific overrides if the coaching profile contains a
|
||||
redistribution_profile.
|
||||
|
||||
Applies usage-efficiency tradeoff: each unit of raw boost carries a
|
||||
penalty of -0.015 per 0.05 boost (i.e. higher boosts are less efficient).
|
||||
|
||||
Args:
|
||||
player_out: Player profile dict of the absent player.
|
||||
coaching: Coaching tendencies dict (may include redistribution_profile).
|
||||
available_roster: List of available teammate profile dicts.
|
||||
|
||||
Returns:
|
||||
List of dicts: [{player_id, name, archetype, raw_boost,
|
||||
efficiency_adjusted_boost}] sorted descending by adjusted boost.
|
||||
"""
|
||||
player_archetype = player_out.get('archetype', 'unknown')
|
||||
base_shifts = SYSTEM_SHIFT_MAP.get(player_archetype, {})
|
||||
|
||||
# Coach-specific overrides take precedence
|
||||
coach_overrides = {}
|
||||
if coaching and coaching.get('redistribution_profile'):
|
||||
profile = coaching['redistribution_profile']
|
||||
coach_overrides = profile.get(player_archetype, {})
|
||||
|
||||
# Merge: coach overrides win
|
||||
effective_shifts = {**base_shifts, **coach_overrides}
|
||||
|
||||
results = []
|
||||
for teammate in available_roster:
|
||||
if teammate.get('player_id') == player_out.get('player_id'):
|
||||
continue
|
||||
teammate_archetype = teammate.get('archetype', 'unknown')
|
||||
raw_boost = effective_shifts.get(teammate_archetype, 0.0)
|
||||
if raw_boost == 0.0:
|
||||
continue
|
||||
|
||||
# Apply usage-efficiency tradeoff
|
||||
penalty = raw_boost * USAGE_EFFICIENCY_PENALTY_PER_UNIT
|
||||
adjusted_boost = round(raw_boost + penalty, 4)
|
||||
|
||||
results.append({
|
||||
'player_id': teammate.get('player_id'),
|
||||
'name': teammate.get('name', 'Unknown'),
|
||||
'archetype': teammate_archetype,
|
||||
'raw_boost': round(raw_boost, 4),
|
||||
'efficiency_adjusted_boost': adjusted_boost,
|
||||
})
|
||||
|
||||
results.sort(
|
||||
key=lambda x: x['efficiency_adjusted_boost'], reverse=True
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classification and formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify_absorption_tier(boost, confidence):
|
||||
"""
|
||||
Classify a teammate's absorption tier based on projected usage boost
|
||||
and confidence level.
|
||||
|
||||
Tiers:
|
||||
- primary: boost >= 0.20 AND confidence >= 0.75
|
||||
- secondary: boost >= 0.10 AND confidence >= 0.60
|
||||
- tertiary: boost >= 0.05
|
||||
- minimal: everything else
|
||||
|
||||
Args:
|
||||
boost: Float, projected usage boost (0.0 - 1.0 scale).
|
||||
confidence: Float, confidence level (0.0 - 1.0).
|
||||
|
||||
Returns:
|
||||
String tier label: 'primary', 'secondary', 'tertiary', or 'minimal'.
|
||||
"""
|
||||
if (
|
||||
boost >= TIER_PRIMARY['min_boost']
|
||||
and confidence >= TIER_PRIMARY['min_confidence']
|
||||
):
|
||||
return 'primary'
|
||||
if (
|
||||
boost >= TIER_SECONDARY['min_boost']
|
||||
and confidence >= TIER_SECONDARY['min_confidence']
|
||||
):
|
||||
return 'secondary'
|
||||
if boost >= TIER_TERTIARY['min_boost']:
|
||||
return 'tertiary'
|
||||
return 'minimal'
|
||||
|
||||
|
||||
def calculate_absorption_confidence(coaching, history_count):
|
||||
"""
|
||||
Calculate confidence score for the redistribution projection based on
|
||||
the quality of coaching data and historical match count.
|
||||
|
||||
Factors:
|
||||
- Coaching data quality: +0.30 if full profile, +0.15 if partial.
|
||||
- Historical events: scaled from 0.0 to 0.50 based on sample size
|
||||
(caps at 20 events for full credit).
|
||||
- Base confidence floor of 0.20 (positional logic always contributes).
|
||||
|
||||
Args:
|
||||
coaching: Coaching tendencies dict (or None).
|
||||
history_count: Int, number of historical player-out events.
|
||||
|
||||
Returns:
|
||||
Float confidence score between 0.20 and 1.0.
|
||||
"""
|
||||
base = 0.20
|
||||
|
||||
# Coaching data quality
|
||||
if coaching and coaching.get('redistribution_profile'):
|
||||
coaching_score = 0.30
|
||||
elif coaching and coaching.get('rotation_depth'):
|
||||
coaching_score = 0.15
|
||||
else:
|
||||
coaching_score = 0.0
|
||||
|
||||
# Historical data contribution (capped at 20 events)
|
||||
capped_count = min(history_count, 20)
|
||||
history_score = (capped_count / 20) * 0.50
|
||||
|
||||
confidence = min(base + coaching_score + history_score, 1.0)
|
||||
return round(confidence, 2)
|
||||
|
||||
|
||||
def format_absorption_alert(player_out, primary_beneficiary):
|
||||
"""
|
||||
Format a human-readable absorption alert for the scanner UI.
|
||||
|
||||
Format:
|
||||
"[Star] is OUT.
|
||||
[Target] is underpriced. Boost: +X%. Confidence: Y%."
|
||||
|
||||
Args:
|
||||
player_out: Dict with at least 'name' key.
|
||||
primary_beneficiary: Dict with 'name', 'boost', and 'confidence' keys.
|
||||
|
||||
Returns:
|
||||
Formatted alert string.
|
||||
"""
|
||||
star_name = player_out.get('name', 'Unknown')
|
||||
target_name = primary_beneficiary.get('name', 'Unknown')
|
||||
boost_pct = round(primary_beneficiary.get('boost', 0.0) * 100, 1)
|
||||
confidence_pct = round(primary_beneficiary.get('confidence', 0.0) * 100)
|
||||
|
||||
return (
|
||||
f'{star_name} is OUT.\n'
|
||||
f'{target_name} is underpriced. '
|
||||
f'Boost: +{boost_pct}%. Confidence: {confidence_pct}%.'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@redistribution_bp.route(
|
||||
'/calculate/<player_out_id>/<game_id>', methods=['GET']
|
||||
)
|
||||
def calculate_redistribution(player_out_id, game_id):
|
||||
"""
|
||||
GET /calculate/<player_out_id>/<game_id>
|
||||
|
||||
Calculate how usage, minutes, and production redistribute when a key
|
||||
player is ruled OUT for a given game.
|
||||
|
||||
Layers:
|
||||
A) Minutes redistribution — historical or positional/coaching fallback.
|
||||
B) System-change modifiers — archetype-driven usage shifts.
|
||||
|
||||
Combines both layers, applies efficiency tradeoff, classifies absorption
|
||||
tiers, and identifies auto-grade targets.
|
||||
|
||||
Returns JSON:
|
||||
{
|
||||
player_out: {...},
|
||||
redistribution: [...],
|
||||
auto_grade_targets: [...],
|
||||
primary_beneficiary: {...},
|
||||
alert: "...",
|
||||
meta: {confidence, source, history_count}
|
||||
}
|
||||
"""
|
||||
logger.info(
|
||||
'Redistribution request: player_out=%s game=%s',
|
||||
player_out_id,
|
||||
game_id,
|
||||
)
|
||||
|
||||
# --- Gather context ---
|
||||
player_out = get_player_profile(player_out_id)
|
||||
if not player_out:
|
||||
return jsonify({
|
||||
'error': 'player_not_found',
|
||||
'message': f'No profile found for player {player_out_id}.',
|
||||
}), 404
|
||||
|
||||
game_context = get_game_context(game_id)
|
||||
if not game_context:
|
||||
return jsonify({
|
||||
'error': 'game_not_found',
|
||||
'message': f'No game context found for {game_id}.',
|
||||
}), 404
|
||||
|
||||
# Determine team and coaching context
|
||||
team_id = player_out.get('team_id')
|
||||
coach = get_team_coach(team_id)
|
||||
coaching = (
|
||||
get_coaching_tendencies(coach.get('coach_id'))
|
||||
if coach and coach.get('coach_id')
|
||||
else None
|
||||
)
|
||||
available_roster = get_available_roster(team_id, game_id)
|
||||
|
||||
if not available_roster:
|
||||
return jsonify({
|
||||
'error': 'no_roster',
|
||||
'message': 'No available roster data for this game.',
|
||||
}), 404
|
||||
|
||||
# --- Layer A: Minutes redistribution ---
|
||||
minutes_redist = calculate_minutes_redistribution(
|
||||
player_out, game_context, coaching, available_roster
|
||||
)
|
||||
|
||||
# --- Layer B: System-change modifiers ---
|
||||
system_changes = calculate_system_change(
|
||||
player_out, coaching, available_roster
|
||||
)
|
||||
|
||||
# --- Combine layers ---
|
||||
history = get_player_out_history(player_out_id)
|
||||
history_count = len(history) if history else 0
|
||||
confidence = calculate_absorption_confidence(coaching, history_count)
|
||||
|
||||
# Build combined redistribution list keyed by player_id
|
||||
combined = {}
|
||||
for entry in minutes_redist:
|
||||
pid = entry['player_id']
|
||||
combined[pid] = {
|
||||
'player_id': pid,
|
||||
'name': entry['name'],
|
||||
'minutes_share': entry['minutes_share'],
|
||||
'usage_boost': 0.0,
|
||||
'raw_boost': 0.0,
|
||||
'source': entry['source'],
|
||||
}
|
||||
|
||||
for entry in system_changes:
|
||||
pid = entry['player_id']
|
||||
if pid in combined:
|
||||
combined[pid]['usage_boost'] = entry['efficiency_adjusted_boost']
|
||||
combined[pid]['raw_boost'] = entry['raw_boost']
|
||||
else:
|
||||
combined[pid] = {
|
||||
'player_id': pid,
|
||||
'name': entry['name'],
|
||||
'minutes_share': 0.0,
|
||||
'usage_boost': entry['efficiency_adjusted_boost'],
|
||||
'raw_boost': entry['raw_boost'],
|
||||
'source': 'system_change_only',
|
||||
}
|
||||
|
||||
# Classify tiers and sort
|
||||
redistribution_list = []
|
||||
for pid, data in combined.items():
|
||||
boost = data['usage_boost']
|
||||
tier = classify_absorption_tier(boost, confidence)
|
||||
data['tier'] = tier
|
||||
data['confidence'] = confidence
|
||||
redistribution_list.append(data)
|
||||
|
||||
redistribution_list.sort(
|
||||
key=lambda x: x['usage_boost'], reverse=True
|
||||
)
|
||||
|
||||
# --- Auto-grade targets ---
|
||||
auto_grade_targets = [
|
||||
entry
|
||||
for entry in redistribution_list
|
||||
if entry['usage_boost'] >= AUTO_GRADE_MIN_BOOST
|
||||
and entry['confidence'] >= AUTO_GRADE_MIN_CONFIDENCE
|
||||
]
|
||||
|
||||
# --- Primary beneficiary and alert ---
|
||||
primary_beneficiary = redistribution_list[0] if redistribution_list else None
|
||||
alert = None
|
||||
if primary_beneficiary:
|
||||
alert = format_absorption_alert(
|
||||
player_out,
|
||||
{
|
||||
'name': primary_beneficiary['name'],
|
||||
'boost': primary_beneficiary['usage_boost'],
|
||||
'confidence': primary_beneficiary['confidence'],
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'player_out': {
|
||||
'player_id': player_out.get('player_id'),
|
||||
'name': player_out.get('name'),
|
||||
'archetype': player_out.get('archetype'),
|
||||
'position': player_out.get('position'),
|
||||
'minutes_per_game': player_out.get('minutes_per_game'),
|
||||
},
|
||||
'redistribution': redistribution_list,
|
||||
'auto_grade_targets': auto_grade_targets,
|
||||
'primary_beneficiary': primary_beneficiary,
|
||||
'alert': alert,
|
||||
'meta': {
|
||||
'confidence': confidence,
|
||||
'source': 'historical' if history_count >= MIN_HISTORICAL_EVENTS
|
||||
else 'positional_coaching_fallback',
|
||||
'history_count': history_count,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH Item 6: MLB Lineup Shift on scratch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def calculate_mlb_lineup_shift(original_lineup, scratched_player_id, new_lineup):
|
||||
"""
|
||||
MLB-specific: when a batter is scratched, lineup positions shift.
|
||||
PA multipliers, RBI context, and lineup protection all change.
|
||||
|
||||
Args:
|
||||
original_lineup: List of dicts with id, name, batting_order.
|
||||
scratched_player_id: ID of the scratched player.
|
||||
new_lineup: List of dicts with id, name, batting_order after scratch.
|
||||
|
||||
Returns:
|
||||
List of affected player dicts with position changes and regrade flags.
|
||||
"""
|
||||
from utils.archetypes import BATTING_ORDER
|
||||
|
||||
affected = []
|
||||
for player in new_lineup:
|
||||
old_pos = _find_original_position(player['id'], original_lineup)
|
||||
new_pos = player.get('batting_order')
|
||||
|
||||
if old_pos and new_pos and old_pos != new_pos:
|
||||
old_mult = BATTING_ORDER.get(old_pos, {}).get('pa_mult', 1.0)
|
||||
new_mult = BATTING_ORDER.get(new_pos, {}).get('pa_mult', 1.0)
|
||||
|
||||
affected.append({
|
||||
'player_id': player['id'],
|
||||
'player_name': player.get('name', ''),
|
||||
'old_position': old_pos,
|
||||
'new_position': new_pos,
|
||||
'pa_mult_change': round(new_mult - old_mult, 3),
|
||||
'new_rbi_context': BATTING_ORDER.get(new_pos, {}).get('rbi_ctx', 'unknown'),
|
||||
'needs_regrade': abs(new_mult - old_mult) > 0.02
|
||||
})
|
||||
|
||||
return affected
|
||||
|
||||
|
||||
def _find_original_position(player_id, lineup):
|
||||
"""Find a player's original batting order position."""
|
||||
for p in lineup:
|
||||
if p.get('id') == player_id:
|
||||
return p.get('batting_order')
|
||||
return None
|
||||
|
||||
|
||||
def log_todays_player_out_events(game_date):
|
||||
"""
|
||||
Log player-out events from today's games for redistribution training.
|
||||
Called by nightly resolution step 15.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
"""
|
||||
logger.info(f'[VYNDR] Logging player-out events for {game_date}')
|
||||
# In production: query injury reports + game logs to find players
|
||||
# who were listed as OUT, then log what happened to teammates' stats
|
||||
|
||||
|
||||
def find_and_log_historical_player_outs(season):
|
||||
"""
|
||||
Historical seeder: find player-out events from a past season.
|
||||
Called by scripts/seed_historical.py.
|
||||
|
||||
Args:
|
||||
season: Season string (e.g., '2024-25').
|
||||
"""
|
||||
logger.info(f'[VYNDR] Finding historical player-out events for {season}')
|
||||
# In production: iterate game logs, cross-reference with injury data
|
||||
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
VYNDR Grade Resolution Pipeline
|
||||
Single nightly job at 2am ET: pull actuals, hit/miss, CLV, alignment,
|
||||
joint outcomes, calibration triggers, global offset, Brier score, blind spots.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.bayesian import calculate_global_offset, calculate_brier_score
|
||||
from utils.blind_spot_detector import detect_model_blind_spots
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
resolution_bp = Blueprint('resolution', __name__)
|
||||
|
||||
NBA_API_DELAY = 0.6
|
||||
|
||||
# Stat type mapping for resolution
|
||||
NBA_STAT_MAP = {
|
||||
'points': 'PTS', 'rebounds': 'REB', 'assists': 'AST',
|
||||
'threes': 'FG3M', 'blocks': 'BLK', 'steals': 'STL',
|
||||
'pts_reb_ast': None # computed
|
||||
}
|
||||
|
||||
MLB_STAT_MAP_PITCHING = {
|
||||
'strikeouts': 'strikeOuts', 'walks': 'baseOnBalls',
|
||||
'innings_pitched': 'inningsPitched', 'hits_allowed': 'hits',
|
||||
'earned_runs': 'earnedRuns'
|
||||
}
|
||||
|
||||
MLB_STAT_MAP_HITTING = {
|
||||
'hits': 'hits', 'home_runs': 'homeRuns', 'rbi': 'rbi',
|
||||
'total_bases': 'totalBases', 'walks': 'baseOnBalls',
|
||||
'runs': 'runs', 'stolen_bases': 'stolenBases'
|
||||
}
|
||||
|
||||
|
||||
def get_nba_actual(player_id, game_date):
|
||||
"""
|
||||
Pull actual stat line from nba_api PlayerGameLog.
|
||||
|
||||
Args:
|
||||
player_id: NBA player ID.
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
Dict with stat values, or None if no game found.
|
||||
"""
|
||||
time.sleep(NBA_API_DELAY)
|
||||
try:
|
||||
from nba_api.stats.endpoints import PlayerGameLog
|
||||
game_log = PlayerGameLog(
|
||||
player_id=player_id,
|
||||
season='2025-26',
|
||||
date_from_nullable=game_date,
|
||||
date_to_nullable=game_date
|
||||
)
|
||||
df = game_log.get_data_frames()[0]
|
||||
if df.empty:
|
||||
return None
|
||||
row = df.iloc[0]
|
||||
return {
|
||||
'points': int(row.get('PTS', 0)),
|
||||
'rebounds': int(row.get('REB', 0)),
|
||||
'assists': int(row.get('AST', 0)),
|
||||
'threes': int(row.get('FG3M', 0)),
|
||||
'blocks': int(row.get('BLK', 0)),
|
||||
'steals': int(row.get('STL', 0)),
|
||||
'pts_reb_ast': int(row.get('PTS', 0)) + int(row.get('REB', 0)) + int(row.get('AST', 0)),
|
||||
'minutes': float(row.get('MIN', 0))
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] NBA actual fetch failed for {player_id}: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def get_mlb_actual(player_id, game_date):
|
||||
"""
|
||||
Pull actual stat line from MLB-StatsAPI.
|
||||
|
||||
Args:
|
||||
player_id: MLB player ID.
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
Dict with stat values, or None if no game found.
|
||||
"""
|
||||
try:
|
||||
import statsapi
|
||||
# Try pitching first
|
||||
try:
|
||||
pitching = statsapi.player_stat_data(player_id, group='pitching', type='gameLog')
|
||||
for game in pitching.get('stats', [{}])[0].get('splits', []):
|
||||
if game.get('date') == game_date:
|
||||
stat = game['stat']
|
||||
return {
|
||||
'strikeouts': stat.get('strikeOuts', 0),
|
||||
'walks': stat.get('baseOnBalls', 0),
|
||||
'innings_pitched': float(stat.get('inningsPitched', 0)),
|
||||
'hits_allowed': stat.get('hits', 0),
|
||||
'earned_runs': stat.get('earnedRuns', 0),
|
||||
'player_type': 'pitcher'
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try hitting
|
||||
try:
|
||||
hitting = statsapi.player_stat_data(player_id, group='hitting', type='gameLog')
|
||||
for game in hitting.get('stats', [{}])[0].get('splits', []):
|
||||
if game.get('date') == game_date:
|
||||
stat = game['stat']
|
||||
return {
|
||||
'hits': stat.get('hits', 0),
|
||||
'home_runs': stat.get('homeRuns', 0),
|
||||
'rbi': stat.get('rbi', 0),
|
||||
'total_bases': stat.get('totalBases', 0),
|
||||
'walks': stat.get('baseOnBalls', 0),
|
||||
'runs': stat.get('runs', 0),
|
||||
'stolen_bases': stat.get('stolenBases', 0),
|
||||
'player_type': 'batter'
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
logger.warning('[VYNDR] statsapi not installed')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def determine_hit_miss(actual_value, prop_line, over_under):
|
||||
"""
|
||||
Determine if a grade was a hit or miss.
|
||||
|
||||
Args:
|
||||
actual_value: Actual stat value achieved.
|
||||
prop_line: The prop line that was graded.
|
||||
over_under: 'over' or 'under'.
|
||||
|
||||
Returns:
|
||||
True if hit, False if miss.
|
||||
"""
|
||||
if over_under == 'over':
|
||||
return actual_value > prop_line
|
||||
else:
|
||||
return actual_value < prop_line
|
||||
|
||||
|
||||
def calculate_clv(grade, morning_odds, pregame_odds):
|
||||
"""
|
||||
Closing Line Value — did the market move toward our position?
|
||||
|
||||
Args:
|
||||
grade: Grade outcome dict with 'over_under'.
|
||||
morning_odds: Morning odds snapshot with 'line'.
|
||||
pregame_odds: Pre-game odds snapshot with 'line'.
|
||||
|
||||
Returns:
|
||||
Dict with opening_line, closing_line, movement, clv_win, clv_magnitude.
|
||||
None if insufficient odds data.
|
||||
"""
|
||||
if not morning_odds or not pregame_odds:
|
||||
return None
|
||||
opening = morning_odds.get('line')
|
||||
closing = pregame_odds.get('line')
|
||||
if opening is None or closing is None:
|
||||
return None
|
||||
movement = closing - opening
|
||||
if grade['over_under'] == 'over':
|
||||
clv_win = movement > 0
|
||||
else:
|
||||
clv_win = movement < 0
|
||||
return {
|
||||
'opening_line': opening,
|
||||
'closing_line': closing,
|
||||
'movement': movement,
|
||||
'clv_win': clv_win,
|
||||
'clv_magnitude': abs(movement)
|
||||
}
|
||||
|
||||
|
||||
def detect_model_market_alignment(grade, opening_line, closing_line):
|
||||
"""
|
||||
Check if market moved WITH or AGAINST VYNDR's position.
|
||||
|
||||
Args:
|
||||
grade: Dict with 'over_under'.
|
||||
opening_line: Morning opening line.
|
||||
closing_line: Pre-game closing line.
|
||||
|
||||
Returns:
|
||||
Dict with model_direction, aligned, movement, signal.
|
||||
None if insufficient data.
|
||||
"""
|
||||
if opening_line is None or closing_line is None:
|
||||
return None
|
||||
movement = closing_line - opening_line
|
||||
if grade['over_under'] == 'over':
|
||||
aligned = movement > 0
|
||||
else:
|
||||
aligned = movement < 0
|
||||
return {
|
||||
'model_direction': grade['over_under'],
|
||||
'aligned': aligned,
|
||||
'movement': abs(movement),
|
||||
'signal': 'confirming' if aligned else 'contrarian'
|
||||
}
|
||||
|
||||
|
||||
def log_joint_outcomes(grade, actual_value, hit, game_date, same_game_grades):
|
||||
"""
|
||||
Log joint outcomes for same-game player pairs.
|
||||
Enables phi coefficient calculation for parlay correlation.
|
||||
|
||||
Args:
|
||||
grade: Current grade outcome dict.
|
||||
actual_value: Actual stat value.
|
||||
hit: Whether this grade hit.
|
||||
game_date: Date string.
|
||||
same_game_grades: List of other resolved grades from same game.
|
||||
|
||||
Returns:
|
||||
List of joint outcome dicts created.
|
||||
"""
|
||||
joints = []
|
||||
for other in same_game_grades:
|
||||
if other.get('id') == grade.get('id'):
|
||||
continue
|
||||
if other.get('resolved_at') is None:
|
||||
continue
|
||||
joints.append({
|
||||
'player_a_id': grade.get('player_id'),
|
||||
'player_b_id': other.get('player_id'),
|
||||
'stat_a': grade.get('stat_type'),
|
||||
'stat_b': other.get('stat_type'),
|
||||
'hit_a': hit,
|
||||
'hit_b': other.get('hit'),
|
||||
'game_date': game_date
|
||||
})
|
||||
return joints
|
||||
|
||||
|
||||
def nightly_resolution_job(game_date, unresolved_grades, get_odds_fn=None):
|
||||
"""
|
||||
Single nightly job — 2am ET via GitHub Actions.
|
||||
Resolves grades, calculates CLV, tracks joint outcomes, triggers calibration.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
unresolved_grades: List of unresolved grade outcome dicts.
|
||||
get_odds_fn: Optional function to fetch odds snapshots.
|
||||
|
||||
Returns:
|
||||
Dict with resolution summary.
|
||||
"""
|
||||
resolved_count = 0
|
||||
hit_count = 0
|
||||
clv_count = 0
|
||||
joint_count = 0
|
||||
errors = []
|
||||
|
||||
for grade in unresolved_grades:
|
||||
try:
|
||||
# Step 1: Pull actual stat line
|
||||
if grade['sport'] == 'nba':
|
||||
actual = get_nba_actual(grade['player_id'], game_date)
|
||||
elif grade['sport'] == 'mlb':
|
||||
actual = get_mlb_actual(grade['player_id'], game_date)
|
||||
else:
|
||||
continue
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
actual_value = actual.get(grade.get('stat_type'))
|
||||
if actual_value is None:
|
||||
continue
|
||||
|
||||
# Step 2: Hit/miss
|
||||
hit = determine_hit_miss(actual_value, grade['prop_line'], grade['over_under'])
|
||||
if hit:
|
||||
hit_count += 1
|
||||
|
||||
# Step 3: CLV (if odds available)
|
||||
clv = None
|
||||
alignment = None
|
||||
if get_odds_fn:
|
||||
morning = get_odds_fn(grade, 'morning_open')
|
||||
pregame = get_odds_fn(grade, 'pre_game')
|
||||
clv = calculate_clv(grade, morning, pregame)
|
||||
if clv:
|
||||
clv_count += 1
|
||||
alignment = detect_model_market_alignment(
|
||||
grade,
|
||||
morning.get('line') if morning else None,
|
||||
pregame.get('line') if pregame else None
|
||||
)
|
||||
|
||||
# Step 4: Joint outcomes
|
||||
same_game = [g for g in unresolved_grades
|
||||
if g.get('game_id') == grade.get('game_id')
|
||||
and g.get('id') != grade.get('id')]
|
||||
joints = log_joint_outcomes(grade, actual_value, hit, game_date, same_game)
|
||||
joint_count += len(joints)
|
||||
|
||||
grade['actual_value'] = actual_value
|
||||
grade['hit'] = hit
|
||||
grade['clv'] = clv
|
||||
grade['alignment'] = alignment
|
||||
grade['joints'] = joints
|
||||
grade['resolved_at'] = datetime.utcnow().isoformat()
|
||||
resolved_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f'{grade.get("player_id")}: {str(e)}')
|
||||
logger.warning(f'[VYNDR] Resolution error: {e}')
|
||||
|
||||
return {
|
||||
'game_date': game_date,
|
||||
'total_unresolved': len(unresolved_grades),
|
||||
'resolved': resolved_count,
|
||||
'hits': hit_count,
|
||||
'misses': resolved_count - hit_count,
|
||||
'hit_rate': round(hit_count / resolved_count, 3) if resolved_count > 0 else None,
|
||||
'clv_tracked': clv_count,
|
||||
'joint_outcomes_logged': joint_count,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
|
||||
def run_supplement_steps(game_date):
|
||||
"""
|
||||
Steps 14-18 of the nightly job — supplement system updates.
|
||||
Called after the main resolution loop completes.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
Dict with step results.
|
||||
"""
|
||||
supplement_results = {}
|
||||
|
||||
# Step 14: Update coaching tendencies from today's games
|
||||
try:
|
||||
from blueprints.coaching import update_coaching_tendencies
|
||||
update_coaching_tendencies(game_date)
|
||||
supplement_results['coaching_update'] = 'ok'
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Coaching update failed: {e}')
|
||||
supplement_results['coaching_update'] = f'error: {e}'
|
||||
|
||||
# Step 15: Log player-out history for redistribution training
|
||||
try:
|
||||
from blueprints.redistribution import log_todays_player_out_events
|
||||
log_todays_player_out_events(game_date)
|
||||
supplement_results['player_out_history'] = 'ok'
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Player-out history failed: {e}')
|
||||
supplement_results['player_out_history'] = f'error: {e}'
|
||||
|
||||
# Step 16: Run evolution detection scan
|
||||
try:
|
||||
from blueprints.evolution import detect_player_evolution
|
||||
supplement_results['evolution_scan'] = 'ok'
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Evolution scan failed: {e}')
|
||||
supplement_results['evolution_scan'] = f'error: {e}'
|
||||
|
||||
# Step 17: Collect unconventional factor data points
|
||||
try:
|
||||
from blueprints.unconventional import collect_daily_factor_data
|
||||
collect_daily_factor_data(game_date)
|
||||
supplement_results['unconventional_collection'] = 'ok'
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Unconventional collection failed: {e}')
|
||||
supplement_results['unconventional_collection'] = f'error: {e}'
|
||||
|
||||
# Step 18: Monthly unconventional validation (1st of each month)
|
||||
try:
|
||||
from datetime import date as date_cls
|
||||
parsed = date_cls.fromisoformat(game_date) if isinstance(game_date, str) else game_date
|
||||
if parsed.day == 1:
|
||||
from blueprints.unconventional import run_monthly_validation
|
||||
run_monthly_validation()
|
||||
supplement_results['monthly_validation'] = 'triggered'
|
||||
else:
|
||||
supplement_results['monthly_validation'] = 'not_due'
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Monthly validation failed: {e}')
|
||||
supplement_results['monthly_validation'] = f'error: {e}'
|
||||
|
||||
logger.info(f'[VYNDR] Supplement steps complete for {game_date}')
|
||||
return supplement_results
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@resolution_bp.route('/resolve/<game_date>', methods=['POST'])
|
||||
def resolve(game_date):
|
||||
"""
|
||||
Trigger nightly resolution for a specific game date.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
JSON with resolution summary.
|
||||
"""
|
||||
# In production, fetch unresolved from Supabase
|
||||
return jsonify({
|
||||
'game_date': game_date,
|
||||
'status': 'triggered',
|
||||
'note': 'Resolution pipeline initiated. Results logged to grade_outcomes.'
|
||||
})
|
||||
|
||||
|
||||
@resolution_bp.route('/status/<game_date>', methods=['GET'])
|
||||
def resolution_status(game_date):
|
||||
"""Check resolution status for a game date."""
|
||||
return jsonify({
|
||||
'game_date': game_date,
|
||||
'resolved_count': 0,
|
||||
'pending_count': 0,
|
||||
'note': 'No grades logged yet'
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
VYNDR Synergy Service — NBA play-type data.
|
||||
Blueprint providing team play types, matchup data, and player tracking stats.
|
||||
Data sourced from nba_api SynergyPlayType, LeagueSeasonMatchups, LeagueDashPtStats.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.retry import api_call_with_retry
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
synergy_bp = Blueprint('synergy', __name__)
|
||||
|
||||
NBA_API_DELAY = 0.6 # seconds between nba_api calls
|
||||
|
||||
PLAY_TYPES = [
|
||||
'Transition', 'Isolation', 'PRBallHandler', 'PRRollman',
|
||||
'Postup', 'Spotup', 'Handoff', 'Cut', 'OffScreen',
|
||||
'OffRebound', 'Misc'
|
||||
]
|
||||
|
||||
|
||||
def _nba_api_delay():
|
||||
"""Enforce 0.6s delay between all nba_api calls."""
|
||||
time.sleep(NBA_API_DELAY)
|
||||
|
||||
|
||||
@synergy_bp.route('/team-playtypes/<team_id>', methods=['GET'])
|
||||
def get_team_playtypes(team_id):
|
||||
"""
|
||||
Get offensive and defensive play type distributions for a team.
|
||||
Sources: nba_api SynergyPlayType. Cache 6hr.
|
||||
|
||||
Args:
|
||||
team_id: NBA team ID.
|
||||
|
||||
Returns:
|
||||
Dict with offensive and defensive play type frequency, PPP, FG%, TO%.
|
||||
"""
|
||||
def _fetch():
|
||||
_nba_api_delay()
|
||||
try:
|
||||
from nba_api.stats.endpoints import SynergyPlayType
|
||||
off_data = SynergyPlayType(
|
||||
play_type_nullable='',
|
||||
type_grouping_nullable='offensive',
|
||||
team_id_nullable=team_id,
|
||||
season='2025-26'
|
||||
)
|
||||
_nba_api_delay()
|
||||
def_data = SynergyPlayType(
|
||||
play_type_nullable='',
|
||||
type_grouping_nullable='defensive',
|
||||
team_id_nullable=team_id,
|
||||
season='2025-26'
|
||||
)
|
||||
return {
|
||||
'offensive': _parse_synergy_df(off_data.get_data_frames()[0]),
|
||||
'defensive': _parse_synergy_df(def_data.get_data_frames()[0])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Synergy fetch failed for team {team_id}: {e}')
|
||||
return None
|
||||
|
||||
data = fetch_with_cache(
|
||||
f'synergy_team_{team_id}',
|
||||
_fetch,
|
||||
data_type='player_stats',
|
||||
has_game_today=False
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return jsonify({'error': 'Synergy data unavailable', 'team_id': team_id}), 503
|
||||
|
||||
return jsonify({
|
||||
'team_id': team_id,
|
||||
'play_types': data,
|
||||
'play_type_count': len(PLAY_TYPES)
|
||||
})
|
||||
|
||||
|
||||
@synergy_bp.route('/matchup/<off_player_id>/<def_player_id>', methods=['GET'])
|
||||
def get_matchup(off_player_id, def_player_id):
|
||||
"""
|
||||
Get head-to-head matchup stats from LeagueSeasonMatchups.
|
||||
|
||||
Args:
|
||||
off_player_id: Offensive player ID.
|
||||
def_player_id: Defensive player ID.
|
||||
|
||||
Returns:
|
||||
H2H stats or null if insufficient data.
|
||||
"""
|
||||
def _fetch():
|
||||
_nba_api_delay()
|
||||
try:
|
||||
from nba_api.stats.endpoints import LeagueSeasonMatchups
|
||||
data = LeagueSeasonMatchups(
|
||||
off_player_id_nullable=off_player_id,
|
||||
def_player_id_nullable=def_player_id,
|
||||
season='2025-26'
|
||||
)
|
||||
df = data.get_data_frames()[0]
|
||||
if df.empty:
|
||||
return None
|
||||
row = df.iloc[0]
|
||||
return {
|
||||
'possessions': int(row.get('POSS', 0)),
|
||||
'player_pts': float(row.get('PLAYER_PTS', 0)),
|
||||
'fg_pct': float(row.get('FG_PCT', 0)),
|
||||
'matchup_quality': 'sufficient' if int(row.get('POSS', 0)) >= 20 else 'limited'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Matchup fetch failed: {e}')
|
||||
return None
|
||||
|
||||
data = fetch_with_cache(
|
||||
f'matchup_{off_player_id}_{def_player_id}',
|
||||
_fetch,
|
||||
data_type='player_stats'
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return jsonify({'matchup': None, 'reason': 'insufficient_data'}), 200
|
||||
|
||||
return jsonify({'matchup': data})
|
||||
|
||||
|
||||
@synergy_bp.route('/player-tracking/<player_id>', methods=['GET'])
|
||||
def get_player_tracking(player_id):
|
||||
"""
|
||||
Get player tracking data from LeagueDashPtStats.
|
||||
Type parameter selects tracking category.
|
||||
|
||||
Args:
|
||||
player_id: NBA player ID.
|
||||
type (query param): One of CatchShoot, PullUpShot, Defense, Drives,
|
||||
Passing, PostTouch, PaintTouch, Rebounding, SpeedDistance.
|
||||
|
||||
Returns:
|
||||
Tracking stats for the specified category.
|
||||
"""
|
||||
tracking_type = request.args.get('type', 'Defense')
|
||||
|
||||
def _fetch():
|
||||
_nba_api_delay()
|
||||
try:
|
||||
from nba_api.stats.endpoints import LeagueDashPtStats
|
||||
data = LeagueDashPtStats(
|
||||
player_or_team='Player',
|
||||
pt_measure_type=tracking_type,
|
||||
season='2025-26'
|
||||
)
|
||||
df = data.get_data_frames()[0]
|
||||
player_row = df[df['PLAYER_ID'] == int(player_id)]
|
||||
if player_row.empty:
|
||||
return None
|
||||
return player_row.iloc[0].to_dict()
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Tracking fetch failed for {player_id}: {e}')
|
||||
return None
|
||||
|
||||
data = fetch_with_cache(
|
||||
f'tracking_{player_id}_{tracking_type}',
|
||||
_fetch,
|
||||
data_type='player_stats'
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return jsonify({'tracking': None, 'type': tracking_type}), 200
|
||||
|
||||
return jsonify({'player_id': player_id, 'type': tracking_type, 'tracking': data})
|
||||
|
||||
|
||||
@synergy_bp.route('/defensive-scheme/<team_id>', methods=['GET'])
|
||||
def get_defensive_scheme(team_id):
|
||||
"""
|
||||
Get full defensive play type distribution for scheme classification.
|
||||
Returns distribution that schemeClassifier.js consumes.
|
||||
|
||||
Args:
|
||||
team_id: NBA team ID.
|
||||
|
||||
Returns:
|
||||
Defensive play type frequency distribution.
|
||||
"""
|
||||
def _fetch():
|
||||
_nba_api_delay()
|
||||
try:
|
||||
from nba_api.stats.endpoints import SynergyPlayType
|
||||
def_data = SynergyPlayType(
|
||||
play_type_nullable='',
|
||||
type_grouping_nullable='defensive',
|
||||
team_id_nullable=team_id,
|
||||
season='2025-26'
|
||||
)
|
||||
return _parse_synergy_df(def_data.get_data_frames()[0])
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Defensive scheme fetch failed: {e}')
|
||||
return None
|
||||
|
||||
data = fetch_with_cache(
|
||||
f'defense_scheme_{team_id}',
|
||||
_fetch,
|
||||
data_type='player_stats',
|
||||
has_game_today=True
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return jsonify({'scheme': None, 'reason': 'synergy_unavailable'}), 200
|
||||
|
||||
return jsonify({'team_id': team_id, 'defensive_distribution': data})
|
||||
|
||||
|
||||
def _parse_synergy_df(df):
|
||||
"""Parse Synergy DataFrame into play type distribution dict."""
|
||||
if df is None or df.empty:
|
||||
return {}
|
||||
result = {}
|
||||
for _, row in df.iterrows():
|
||||
play_type = row.get('PLAY_TYPE', 'Unknown')
|
||||
result[play_type] = {
|
||||
'frequency_pct': float(row.get('POSS_PCT', 0)),
|
||||
'ppp': float(row.get('PPP', 0)),
|
||||
'fg_pct': float(row.get('FG_PCT', 0)),
|
||||
'to_pct': float(row.get('TOV_PCT', 0))
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
VYNDR Unconventional Data Pipeline — Blueprint
|
||||
Validates and applies unconventional factors (altitude, contract year, referee
|
||||
crew history, travel distance, arena altitude) to prop adjustments.
|
||||
Statistical validation via Pearson r with Bonferroni correction.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
from scipy.stats import pearsonr
|
||||
|
||||
from utils.data_warehouse import get_factor_outcomes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
unconventional_bp = Blueprint('unconventional', __name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation thresholds
|
||||
# ---------------------------------------------------------------------------
|
||||
VALIDATION_REQUIREMENTS = {
|
||||
"min_historical_instances": 500,
|
||||
"min_pearson_r": 0.15,
|
||||
"max_p_value": 0.05, # before Bonferroni
|
||||
"bonferroni_correction": True,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor registry
|
||||
# ---------------------------------------------------------------------------
|
||||
UNCONVENTIONAL_FACTORS = {
|
||||
"altitude_adjustment": {
|
||||
"description": "Adjusts projections for games played at high altitude venues",
|
||||
"data_source": "venue_metadata",
|
||||
"affects": ["points", "rebounds", "total_bases"],
|
||||
"validated": False,
|
||||
},
|
||||
"contract_year": {
|
||||
"description": "Players in the final year of their contract tend to show elevated performance",
|
||||
"data_source": "contract_database",
|
||||
"affects": ["points", "rebounds", "assists"],
|
||||
"validated": False,
|
||||
},
|
||||
"referee_crew_history": {
|
||||
"description": "Historical tendencies of assigned referee crews on game totals and foul rates",
|
||||
"data_source": "referee_assignments",
|
||||
"affects": ["points", "rebounds"],
|
||||
"validated": False,
|
||||
},
|
||||
"travel_distance": {
|
||||
"description": "Fatigue signal derived from miles traveled in the preceding 48 hours",
|
||||
"data_source": "schedule_geodata",
|
||||
"affects": ["points", "rebounds", "assists"],
|
||||
"validated": True,
|
||||
},
|
||||
"arena_altitude": {
|
||||
"description": "Physiological impact of arena elevation on cardio-intensive stats",
|
||||
"data_source": "venue_metadata",
|
||||
"affects": ["points", "assists", "minutes"],
|
||||
"validated": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core validation logic
|
||||
# ---------------------------------------------------------------------------
|
||||
def validate_unconventional_factor(factor_name, outcomes_data):
|
||||
"""
|
||||
Run statistical validation on an unconventional factor.
|
||||
|
||||
Requires at least 500 historical instances. Computes Pearson r and
|
||||
applies Bonferroni correction across all currently-unvalidated factors.
|
||||
|
||||
Args:
|
||||
factor_name: Key into UNCONVENTIONAL_FACTORS.
|
||||
outcomes_data: Dict with 'factor_values' and 'outcome_values' lists
|
||||
of equal length.
|
||||
|
||||
Returns:
|
||||
Dict with validation verdict and supporting statistics.
|
||||
"""
|
||||
if factor_name not in UNCONVENTIONAL_FACTORS:
|
||||
return {"error": f"Unknown factor: {factor_name}"}
|
||||
|
||||
factor_values = outcomes_data.get("factor_values", [])
|
||||
outcome_values = outcomes_data.get("outcome_values", [])
|
||||
|
||||
sample_size = len(factor_values)
|
||||
min_instances = VALIDATION_REQUIREMENTS["min_historical_instances"]
|
||||
|
||||
if sample_size < min_instances:
|
||||
return {
|
||||
"validated": False,
|
||||
"reason": f"Insufficient data: {sample_size} < {min_instances} required instances",
|
||||
"sample_size": sample_size,
|
||||
}
|
||||
|
||||
# Pearson correlation
|
||||
r, p_value = pearsonr(factor_values, outcome_values)
|
||||
|
||||
# Bonferroni correction — divide alpha by number of active (unvalidated) tests
|
||||
num_active_tests = sum(
|
||||
1 for f in UNCONVENTIONAL_FACTORS.values() if not f["validated"]
|
||||
)
|
||||
corrected_alpha = VALIDATION_REQUIREMENTS["max_p_value"] / max(num_active_tests, 1)
|
||||
|
||||
passed = abs(r) >= VALIDATION_REQUIREMENTS["min_pearson_r"] and p_value < corrected_alpha
|
||||
|
||||
if passed:
|
||||
UNCONVENTIONAL_FACTORS[factor_name]["validated"] = True
|
||||
logger.info(
|
||||
"Factor '%s' VALIDATED — r=%.4f, p=%.6f, alpha=%.6f, n=%d",
|
||||
factor_name, r, p_value, corrected_alpha, sample_size,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Factor '%s' FAILED validation — r=%.4f, p=%.6f, alpha=%.6f, n=%d",
|
||||
factor_name, r, p_value, corrected_alpha, sample_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"validated": passed,
|
||||
"pearson_r": round(r, 6),
|
||||
"p_value": round(p_value, 8),
|
||||
"corrected_alpha": round(corrected_alpha, 6),
|
||||
"sample_size": sample_size,
|
||||
"bonferroni_tests": num_active_tests,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adjustment helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_adjustment_value(factor_name, player_id):
|
||||
"""
|
||||
Compute the adjustment value for a validated factor and player.
|
||||
|
||||
Returns 0.0 when the factor is not yet validated.
|
||||
"""
|
||||
factor = UNCONVENTIONAL_FACTORS.get(factor_name)
|
||||
if not factor or not factor["validated"]:
|
||||
return 0.0
|
||||
|
||||
outcomes = get_factor_outcomes(factor_name, player_id)
|
||||
if not outcomes or not outcomes.get("adjustment"):
|
||||
return 0.0
|
||||
|
||||
return outcomes["adjustment"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@unconventional_bp.route("/validate/<factor_name>", methods=["POST"])
|
||||
def validate_factor(factor_name):
|
||||
"""Manually trigger validation for a single unconventional factor."""
|
||||
if factor_name not in UNCONVENTIONAL_FACTORS:
|
||||
return jsonify({"error": f"Unknown factor: {factor_name}"}), 404
|
||||
|
||||
try:
|
||||
outcomes_data = get_factor_outcomes(factor_name)
|
||||
result = validate_unconventional_factor(factor_name, outcomes_data)
|
||||
return jsonify(result), 200
|
||||
except Exception as exc:
|
||||
logger.exception("Validation failed for factor '%s'", factor_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@unconventional_bp.route("/status", methods=["GET"])
|
||||
def factor_status():
|
||||
"""Return all unconventional factors with their current validation state."""
|
||||
return jsonify(UNCONVENTIONAL_FACTORS), 200
|
||||
|
||||
|
||||
@unconventional_bp.route("/adjustment/<factor_name>/<player_id>", methods=["GET"])
|
||||
def get_adjustment(factor_name, player_id):
|
||||
"""
|
||||
Get the prop adjustment value for a validated factor and player.
|
||||
|
||||
Returns 0.0 if the factor has not been validated.
|
||||
"""
|
||||
if factor_name not in UNCONVENTIONAL_FACTORS:
|
||||
return jsonify({"error": f"Unknown factor: {factor_name}"}), 404
|
||||
|
||||
adjustment = _get_adjustment_value(factor_name, player_id)
|
||||
factor = UNCONVENTIONAL_FACTORS[factor_name]
|
||||
|
||||
return jsonify({
|
||||
"factor": factor_name,
|
||||
"player_id": player_id,
|
||||
"adjustment": adjustment,
|
||||
"validated": factor["validated"],
|
||||
"affects": factor["affects"],
|
||||
}), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH Item 9: Daily data collection + monthly validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_daily_factor_data(game_date):
|
||||
"""
|
||||
Collect unconventional factor data points alongside regular game data.
|
||||
Called by nightly resolution step 17. Accumulates so monthly validation
|
||||
has something to validate against.
|
||||
|
||||
Args:
|
||||
game_date: Date string (YYYY-MM-DD).
|
||||
"""
|
||||
logger.info(f'[VYNDR] Collecting unconventional factor data for {game_date}')
|
||||
# In production: iterate completed games, check each factor
|
||||
# For altitude: log games at venues > 3000ft
|
||||
# For contract year: check player contract status
|
||||
# For referee crew: log crew assignments
|
||||
# Store via log_factor_data
|
||||
|
||||
|
||||
def log_factor_data(factor_name, game_id, game_date, extra_data):
|
||||
"""
|
||||
Store a data point for future validation.
|
||||
|
||||
Args:
|
||||
factor_name: Factor identifier string.
|
||||
game_id: Game identifier.
|
||||
game_date: Date string.
|
||||
extra_data: Dict of factor-specific data.
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if supabase:
|
||||
supabase.table('unconventional_factor_data').insert({
|
||||
'factor_name': factor_name,
|
||||
'game_id': game_id,
|
||||
'game_date': game_date,
|
||||
'factor_value': json.dumps(extra_data),
|
||||
}).execute()
|
||||
except Exception as e:
|
||||
logger.warning(f'[VYNDR] Factor data log failed: {e}')
|
||||
|
||||
|
||||
def run_monthly_validation():
|
||||
"""
|
||||
Run validation on all unvalidated factors. Called on 1st of each month
|
||||
by nightly resolution step 18.
|
||||
"""
|
||||
logger.info('[VYNDR] Running monthly unconventional factor validation')
|
||||
for factor_name, factor in UNCONVENTIONAL_FACTORS.items():
|
||||
if factor['validated']:
|
||||
continue
|
||||
logger.info(f'[VYNDR] Validating {factor_name}...')
|
||||
# In production: fetch outcomes from unconventional_factor_data
|
||||
# and run validate_unconventional_factor
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"grade_scale": {
|
||||
"A+": {"low": 0.85, "high": 1.00},
|
||||
"A": {"low": 0.78, "high": 0.84},
|
||||
"A-": {"low": 0.72, "high": 0.77},
|
||||
"B+": {"low": 0.66, "high": 0.71},
|
||||
"B": {"low": 0.60, "high": 0.65},
|
||||
"B-": {"low": 0.55, "high": 0.59},
|
||||
"C+": {"low": 0.50, "high": 0.54},
|
||||
"C": {"low": 0.45, "high": 0.49},
|
||||
"C-": {"low": 0.40, "high": 0.44},
|
||||
"D": {"low": 0.30, "high": 0.39},
|
||||
"F": {"low": 0.00, "high": 0.29}
|
||||
},
|
||||
"capper_minimum_grade": "A-",
|
||||
"abstention_confidence_range": [0.40, 0.55],
|
||||
"abstention_similar_games_below": 3,
|
||||
"global_offset_clamp": 0.15,
|
||||
"calibration_thresholds_per_player": [25, 50, 75, 100],
|
||||
"global_offset_thresholds": [100, 250, 500, 1000],
|
||||
"point_biserial_bounds": {"min": 0.05, "max": 0.50},
|
||||
"shadow_mode": true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"base_url": "https://api.the-odds-api.com/v4/sports",
|
||||
"sport_keys": {
|
||||
"nba": "basketball_nba",
|
||||
"mlb": "baseball_mlb"
|
||||
},
|
||||
"regions": "us",
|
||||
"odds_format": "american",
|
||||
"bookmakers": ["draftkings", "fanduel", "betmgm", "caesars"],
|
||||
"market_priority": [
|
||||
"pitcher_strikeouts",
|
||||
"player_points",
|
||||
"player_rebounds",
|
||||
"player_assists",
|
||||
"batter_hits",
|
||||
"batter_total_bases"
|
||||
],
|
||||
"free_tier": {
|
||||
"max_daily_pulls": 2,
|
||||
"morning_scan_time": "10:00 AM ET",
|
||||
"pre_game_scan_offset_minutes": 90,
|
||||
"monthly_request_limit": 500
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
[
|
||||
{
|
||||
"park_id": "ARI",
|
||||
"name": "Chase Field",
|
||||
"lat": 33.4455,
|
||||
"lng": -112.0667,
|
||||
"altitude_ft": 1082,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 1.05,
|
||||
"hr_factor": 1.08,
|
||||
"timezone": "America/Phoenix"
|
||||
},
|
||||
{
|
||||
"park_id": "ATL",
|
||||
"name": "Truist Park",
|
||||
"lat": 33.8907,
|
||||
"lng": -84.4677,
|
||||
"altitude_ft": 1050,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.01,
|
||||
"hr_factor": 1.04,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "BAL",
|
||||
"name": "Oriole Park at Camden Yards",
|
||||
"lat": 39.2838,
|
||||
"lng": -76.6216,
|
||||
"altitude_ft": 30,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.02,
|
||||
"hr_factor": 1.07,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "BOS",
|
||||
"name": "Fenway Park",
|
||||
"lat": 42.3467,
|
||||
"lng": -71.0972,
|
||||
"altitude_ft": 20,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.06,
|
||||
"hr_factor": 0.98,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "CHC",
|
||||
"name": "Wrigley Field",
|
||||
"lat": 41.9484,
|
||||
"lng": -87.6553,
|
||||
"altitude_ft": 600,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.04,
|
||||
"hr_factor": 1.09,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "CHW",
|
||||
"name": "Guaranteed Rate Field",
|
||||
"lat": 41.8299,
|
||||
"lng": -87.6338,
|
||||
"altitude_ft": 595,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.05,
|
||||
"hr_factor": 1.12,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "CIN",
|
||||
"name": "Great American Ball Park",
|
||||
"lat": 39.0974,
|
||||
"lng": -84.5065,
|
||||
"altitude_ft": 490,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.08,
|
||||
"hr_factor": 1.16,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "CLE",
|
||||
"name": "Progressive Field",
|
||||
"lat": 41.4962,
|
||||
"lng": -81.6852,
|
||||
"altitude_ft": 660,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.97,
|
||||
"hr_factor": 0.96,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "COL",
|
||||
"name": "Coors Field",
|
||||
"lat": 39.7561,
|
||||
"lng": -104.9942,
|
||||
"altitude_ft": 5200,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.28,
|
||||
"hr_factor": 1.30,
|
||||
"timezone": "America/Denver"
|
||||
},
|
||||
{
|
||||
"park_id": "DET",
|
||||
"name": "Comerica Park",
|
||||
"lat": 42.3390,
|
||||
"lng": -83.0485,
|
||||
"altitude_ft": 600,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.95,
|
||||
"hr_factor": 0.92,
|
||||
"timezone": "America/Detroit"
|
||||
},
|
||||
{
|
||||
"park_id": "HOU",
|
||||
"name": "Minute Maid Park",
|
||||
"lat": 29.7573,
|
||||
"lng": -95.3555,
|
||||
"altitude_ft": 40,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 1.03,
|
||||
"hr_factor": 1.06,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "KC",
|
||||
"name": "Kauffman Stadium",
|
||||
"lat": 39.0517,
|
||||
"lng": -94.4803,
|
||||
"altitude_ft": 820,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.97,
|
||||
"hr_factor": 0.93,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "LAA",
|
||||
"name": "Angel Stadium",
|
||||
"lat": 33.8003,
|
||||
"lng": -117.8827,
|
||||
"altitude_ft": 160,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.96,
|
||||
"hr_factor": 0.97,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "LAD",
|
||||
"name": "Dodger Stadium",
|
||||
"lat": 34.0739,
|
||||
"lng": -118.2400,
|
||||
"altitude_ft": 515,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.94,
|
||||
"hr_factor": 0.93,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "MIA",
|
||||
"name": "LoanDepot Park",
|
||||
"lat": 25.7781,
|
||||
"lng": -80.2196,
|
||||
"altitude_ft": 7,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 0.91,
|
||||
"hr_factor": 0.86,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "MIL",
|
||||
"name": "American Family Field",
|
||||
"lat": 43.0280,
|
||||
"lng": -87.9712,
|
||||
"altitude_ft": 600,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 1.03,
|
||||
"hr_factor": 1.10,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "MIN",
|
||||
"name": "Target Field",
|
||||
"lat": 44.9818,
|
||||
"lng": -93.2776,
|
||||
"altitude_ft": 815,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.00,
|
||||
"hr_factor": 1.02,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "NYM",
|
||||
"name": "Citi Field",
|
||||
"lat": 40.7571,
|
||||
"lng": -73.8458,
|
||||
"altitude_ft": 15,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.93,
|
||||
"hr_factor": 0.90,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "NYY",
|
||||
"name": "Yankee Stadium",
|
||||
"lat": 40.8296,
|
||||
"lng": -73.9262,
|
||||
"altitude_ft": 55,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.05,
|
||||
"hr_factor": 1.15,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "OAK",
|
||||
"name": "Oakland Coliseum",
|
||||
"lat": 37.7516,
|
||||
"lng": -122.2005,
|
||||
"altitude_ft": 5,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.93,
|
||||
"hr_factor": 0.88,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "PHI",
|
||||
"name": "Citizens Bank Park",
|
||||
"lat": 39.9061,
|
||||
"lng": -75.1665,
|
||||
"altitude_ft": 20,
|
||||
"roof_status": "open",
|
||||
"park_factor": 1.06,
|
||||
"hr_factor": 1.13,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "PIT",
|
||||
"name": "PNC Park",
|
||||
"lat": 40.4469,
|
||||
"lng": -80.0058,
|
||||
"altitude_ft": 730,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.96,
|
||||
"hr_factor": 0.91,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "SD",
|
||||
"name": "Petco Park",
|
||||
"lat": 32.7076,
|
||||
"lng": -117.1570,
|
||||
"altitude_ft": 15,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.92,
|
||||
"hr_factor": 0.88,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "SF",
|
||||
"name": "Oracle Park",
|
||||
"lat": 37.7786,
|
||||
"lng": -122.3893,
|
||||
"altitude_ft": 5,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.92,
|
||||
"hr_factor": 0.85,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "SEA",
|
||||
"name": "T-Mobile Park",
|
||||
"lat": 47.5914,
|
||||
"lng": -122.3325,
|
||||
"altitude_ft": 20,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 0.94,
|
||||
"hr_factor": 0.91,
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
{
|
||||
"park_id": "STL",
|
||||
"name": "Busch Stadium",
|
||||
"lat": 38.6226,
|
||||
"lng": -90.1928,
|
||||
"altitude_ft": 455,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.98,
|
||||
"hr_factor": 1.01,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "TB",
|
||||
"name": "Tropicana Field",
|
||||
"lat": 27.7682,
|
||||
"lng": -82.6534,
|
||||
"altitude_ft": 45,
|
||||
"roof_status": "dome",
|
||||
"park_factor": 0.91,
|
||||
"hr_factor": 0.95,
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"park_id": "TEX",
|
||||
"name": "Globe Life Field",
|
||||
"lat": 32.7473,
|
||||
"lng": -97.0845,
|
||||
"altitude_ft": 545,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 1.01,
|
||||
"hr_factor": 1.05,
|
||||
"timezone": "America/Chicago"
|
||||
},
|
||||
{
|
||||
"park_id": "TOR",
|
||||
"name": "Rogers Centre",
|
||||
"lat": 43.6414,
|
||||
"lng": -79.3894,
|
||||
"altitude_ft": 270,
|
||||
"roof_status": "retractable",
|
||||
"park_factor": 1.02,
|
||||
"hr_factor": 1.08,
|
||||
"timezone": "America/Toronto"
|
||||
},
|
||||
{
|
||||
"park_id": "WSH",
|
||||
"name": "Nationals Park",
|
||||
"lat": 38.8730,
|
||||
"lng": -77.0074,
|
||||
"altitude_ft": 25,
|
||||
"roof_status": "open",
|
||||
"park_factor": 0.99,
|
||||
"hr_factor": 1.01,
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
]
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"nba": {
|
||||
"ATL": [{"handle": "@KLChouinard", "outlet": "Atlanta Hawks", "source_type": "beat_writer"},
|
||||
{"handle": "@williamslaurenl", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"BOS": [{"handle": "@ByJayKing", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@john_karalis", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@ChrisForsberg_", "outlet": "NBC Sports Boston", "source_type": "beat_writer"}],
|
||||
"BKN": [{"handle": "@erikslater_", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@nypost_lewis", "outlet": "NY Post", "source_type": "beat_writer"}],
|
||||
"CHA": [{"handle": "@rodboone", "outlet": "Charlotte Observer", "source_type": "beat_writer"},
|
||||
{"handle": "@british_buzz", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"CHI": [{"handle": "@KCJohnson", "outlet": "NBC Sports Chicago", "source_type": "beat_writer"},
|
||||
{"handle": "@byjuliapoe", "outlet": "Chicago Tribune", "source_type": "beat_writer"}],
|
||||
"CLE": [{"handle": "@ChrisFedor", "outlet": "Cleveland.com", "source_type": "beat_writer"},
|
||||
{"handle": "@evandammarell", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"DET": [{"handle": "@omarisankofa", "outlet": "Detroit Free Press", "source_type": "beat_writer"},
|
||||
{"handle": "@CotyDavis", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"IND": [{"handle": "@DustinDopirak", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@ScottAgness", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"MIA": [{"handle": "@AnthonyChiang", "outlet": "Miami Herald", "source_type": "beat_writer"},
|
||||
{"handle": "@IraHeatBeat", "outlet": "Sun Sentinel", "source_type": "beat_writer"}],
|
||||
"MIL": [{"handle": "@EricNehm", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"NYK": [{"handle": "@IanBegley", "outlet": "SNY", "source_type": "beat_writer"},
|
||||
{"handle": "@StevePopper", "outlet": "Newsday", "source_type": "beat_writer"}],
|
||||
"ORL": [{"handle": "@JasonBeede", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"PHI": [{"handle": "@KeithPompey", "outlet": "Philadelphia Inquirer", "source_type": "beat_writer"},
|
||||
{"handle": "@KyleNeubeck", "outlet": "PhillyVoice", "source_type": "beat_writer"}],
|
||||
"TOR": [{"handle": "@JoshLewenberg", "outlet": "TSN", "source_type": "beat_writer"},
|
||||
{"handle": "@MGrange", "outlet": "Sportsnet", "source_type": "beat_writer"}],
|
||||
"WAS": [{"handle": "@ChaseHughes", "outlet": "NBC Sports Washington", "source_type": "beat_writer"},
|
||||
{"handle": "@JoshRobbins", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"DAL": [{"handle": "@GrantAfseth", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@MikeCurtis", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"DEN": [{"handle": "@BennettDurando", "outlet": "Denver Post", "source_type": "beat_writer"},
|
||||
{"handle": "@msinger", "outlet": "Denver Post", "source_type": "beat_writer"}],
|
||||
"GSW": [{"handle": "@anthonyVslater", "outlet": "The Athletic", "source_type": "beat_writer"},
|
||||
{"handle": "@SamGordon", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"HOU": [{"handle": "@JonathanFeigen", "outlet": "Houston Chronicle", "source_type": "beat_writer"}],
|
||||
"LAC": [{"handle": "@JoeyLinn", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@LawMurray", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"LAL": [{"handle": "@MikeTrudell", "outlet": "Spectrum SportsNet", "source_type": "beat_writer"},
|
||||
{"handle": "@JovanBuha", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"MEM": [{"handle": "@DamichaelCole", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@DrewHill", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"MIN": [{"handle": "@ChrisHine", "outlet": "Star Tribune", "source_type": "beat_writer"},
|
||||
{"handle": "@JonKrawczynski", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"NOP": [{"handle": "@Jim_Eichenhofer", "outlet": "Pelicans.com", "source_type": "beat_writer"},
|
||||
{"handle": "@WillGuillory", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"OKC": [{"handle": "@BrandonRahbar", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@RylanStiles", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"PHX": [{"handle": "@DuaneRankin", "outlet": "AZ Republic", "source_type": "beat_writer"},
|
||||
{"handle": "@KellanOlson", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"POR": [{"handle": "@CaseyHoldahl", "outlet": "TrailBlazers.com", "source_type": "beat_writer"},
|
||||
{"handle": "@SeanHighkin", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"SAC": [{"handle": "@James_Ham", "outlet": "NBC Sports Sacramento", "source_type": "beat_writer"},
|
||||
{"handle": "@SeanCunningham", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"SAS": [{"handle": "@JeffMcDonald", "outlet": "San Antonio Express-News", "source_type": "beat_writer"}],
|
||||
"UTA": [{"handle": "@AndyBlarsen", "outlet": "Salt Lake Tribune", "source_type": "beat_writer"},
|
||||
{"handle": "@SarahTodd", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"_aggregators": [
|
||||
{"handle": "@FantasyLabsNBA", "outlet": "FantasyLabs", "source_type": "aggregator"},
|
||||
{"handle": "@UnderdogNBA", "outlet": "Underdog", "source_type": "aggregator"},
|
||||
{"handle": "@NBAInjuryR3port", "outlet": "Independent", "source_type": "aggregator"}
|
||||
],
|
||||
"_national": [
|
||||
{"handle": "@ShamsCharania", "outlet": "ESPN", "source_type": "national"},
|
||||
{"handle": "@wojespn", "outlet": "ESPN", "source_type": "national"}
|
||||
]
|
||||
},
|
||||
"wnba": {
|
||||
"ATL": [{"handle": "@WiltonReports", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"CHI": [{"handle": "@byjuliapoe", "outlet": "Chicago Tribune", "source_type": "beat_writer"}],
|
||||
"CON": [{"handle": "@eaadams6", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"DAL": [{"handle": "@DorothyJGentry", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"GSV": [{"handle": "@nathancanilao", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"IND": [{"handle": "@chloepeterson67", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"LVA": [{"handle": "@CallieFin", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"LAS": [{"handle": "@RahshaunHaylock", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"MIN": [{"handle": "@MitchellHansen", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"NYL": [{"handle": "@MylesEhrlich", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"PHX": [{"handle": "@DanaScott", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"SEA": [{"handle": "@PercyAllen", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"WAS": [{"handle": "@jennhatfield1", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"_aggregators": [
|
||||
{"handle": "@UnderdogWNBA", "outlet": "Underdog", "source_type": "aggregator"},
|
||||
{"handle": "@herhoopstats", "outlet": "Independent", "source_type": "aggregator"},
|
||||
{"handle": "@howardmegdal", "outlet": "The IX", "source_type": "insider"}
|
||||
]
|
||||
},
|
||||
"mlb": {
|
||||
"_note": "Full 30-team beat writer list available at travispflanz.com/mlb-beat-writers-on-twitter. Examples below.",
|
||||
"HOU": [{"handle": "@Chandler_Rome", "outlet": "Houston Chronicle", "source_type": "beat_writer"},
|
||||
{"handle": "@brianmctaggart", "outlet": "MLB.com", "source_type": "beat_writer"}],
|
||||
"ATL": [{"handle": "@mlbbowman", "outlet": "MLB.com", "source_type": "beat_writer"}],
|
||||
"NYY": [{"handle": "@BryanHoch", "outlet": "MLB.com", "source_type": "beat_writer"}],
|
||||
"NYM": [{"handle": "@AnthonyDiComo", "outlet": "MLB.com", "source_type": "beat_writer"}],
|
||||
"SDP": [{"handle": "@AJCassavell", "outlet": "MLB.com", "source_type": "beat_writer"}],
|
||||
"ARI": [{"handle": "@ZHBuchanan", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"BOS": [{"handle": "@PeteAbe", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"_aggregators": [
|
||||
{"handle": "@MLBRosterStatus", "outlet": "Independent", "source_type": "aggregator"}
|
||||
]
|
||||
},
|
||||
"nfl": {
|
||||
"DAL": [{"handle": "@Kyle_Youmans", "outlet": "Local", "source_type": "beat_writer"},
|
||||
{"handle": "@ClarenceHillJr", "outlet": "Fort Worth Star-Telegram", "source_type": "beat_writer"}],
|
||||
"WAS": [{"handle": "@BenStandig", "outlet": "The Athletic", "source_type": "beat_writer"},
|
||||
{"handle": "@john_keim", "outlet": "ESPN", "source_type": "beat_writer"}],
|
||||
"NYG": [{"handle": "@JordanRaanan", "outlet": "ESPN", "source_type": "beat_writer"}],
|
||||
"PHI": [{"handle": "@Jeff_McLane", "outlet": "Philadelphia Inquirer", "source_type": "beat_writer"}],
|
||||
"GBP": [{"handle": "@AndyHermanNFL", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"MIN": [{"handle": "@alec_lewis", "outlet": "The Athletic", "source_type": "beat_writer"}],
|
||||
"CHI": [{"handle": "@BradBiggs", "outlet": "Chicago Tribune", "source_type": "beat_writer"}],
|
||||
"DET": [{"handle": "@colton_pouncy", "outlet": "Local", "source_type": "beat_writer"}],
|
||||
"_aggregators": [
|
||||
{"handle": "@32BeatWriters", "outlet": "Independent", "source_type": "aggregator"},
|
||||
{"handle": "@UnderdogNFL", "outlet": "Underdog", "source_type": "aggregator"},
|
||||
{"handle": "@NFLInjuryNws", "outlet": "Independent", "source_type": "aggregator"},
|
||||
{"handle": "@DrJesseMorse", "outlet": "Independent", "source_type": "insider"}
|
||||
],
|
||||
"_national": [
|
||||
{"handle": "@AdamSchefter", "outlet": "ESPN", "source_type": "national"},
|
||||
{"handle": "@RapSheet", "outlet": "NFL Network", "source_type": "national"},
|
||||
{"handle": "@FieldYates", "outlet": "ESPN", "source_type": "national"}
|
||||
]
|
||||
},
|
||||
"nhl": {
|
||||
"_aggregators": [
|
||||
{"handle": "@NHLBeatWriters", "outlet": "Independent", "source_type": "aggregator"}
|
||||
],
|
||||
"_beats_sample": [
|
||||
{"handle": "@RussoHockey", "outlet": "The Athletic", "team_id": "MIN", "source_type": "beat_writer"},
|
||||
{"handle": "@samnestler", "outlet": "Local", "team_id": "DAL", "source_type": "beat_writer"},
|
||||
{"handle": "@WaltRuff", "outlet": "Local", "team_id": "CAR", "source_type": "beat_writer"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"ATL": {
|
||||
"arena": "State Farm Arena",
|
||||
"city": "Atlanta",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"BOS": {
|
||||
"arena": "TD Garden",
|
||||
"city": "Boston",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"BKN": {
|
||||
"arena": "Barclays Center",
|
||||
"city": "Brooklyn",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"CHA": {
|
||||
"arena": "Spectrum Center",
|
||||
"city": "Charlotte",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"CHI": {
|
||||
"arena": "United Center",
|
||||
"city": "Chicago",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"CLE": {
|
||||
"arena": "Rocket Mortgage FieldHouse",
|
||||
"city": "Cleveland",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"DAL": {
|
||||
"arena": "American Airlines Center",
|
||||
"city": "Dallas",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"DEN": {
|
||||
"arena": "Ball Arena",
|
||||
"city": "Denver",
|
||||
"timezone": "America/Denver",
|
||||
"utc_offset": -7
|
||||
},
|
||||
"DET": {
|
||||
"arena": "Little Caesars Arena",
|
||||
"city": "Detroit",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"GSW": {
|
||||
"arena": "Chase Center",
|
||||
"city": "San Francisco",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"utc_offset": -8
|
||||
},
|
||||
"HOU": {
|
||||
"arena": "Toyota Center",
|
||||
"city": "Houston",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"IND": {
|
||||
"arena": "Gainbridge Fieldhouse",
|
||||
"city": "Indianapolis",
|
||||
"timezone": "America/Indiana/Indianapolis",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"LAC": {
|
||||
"arena": "Intuit Dome",
|
||||
"city": "Inglewood",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"utc_offset": -8
|
||||
},
|
||||
"LAL": {
|
||||
"arena": "Crypto.com Arena",
|
||||
"city": "Los Angeles",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"utc_offset": -8
|
||||
},
|
||||
"MEM": {
|
||||
"arena": "FedExForum",
|
||||
"city": "Memphis",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"MIA": {
|
||||
"arena": "Kaseya Center",
|
||||
"city": "Miami",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"MIL": {
|
||||
"arena": "Fiserv Forum",
|
||||
"city": "Milwaukee",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"MIN": {
|
||||
"arena": "Target Center",
|
||||
"city": "Minneapolis",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"NOP": {
|
||||
"arena": "Smoothie King Center",
|
||||
"city": "New Orleans",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"NYK": {
|
||||
"arena": "Madison Square Garden",
|
||||
"city": "New York",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"OKC": {
|
||||
"arena": "Paycom Center",
|
||||
"city": "Oklahoma City",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"ORL": {
|
||||
"arena": "Amway Center",
|
||||
"city": "Orlando",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"PHI": {
|
||||
"arena": "Wells Fargo Center",
|
||||
"city": "Philadelphia",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"PHX": {
|
||||
"arena": "Footprint Center",
|
||||
"city": "Phoenix",
|
||||
"timezone": "America/Phoenix",
|
||||
"utc_offset": -7
|
||||
},
|
||||
"POR": {
|
||||
"arena": "Moda Center",
|
||||
"city": "Portland",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"utc_offset": -8
|
||||
},
|
||||
"SAC": {
|
||||
"arena": "Golden 1 Center",
|
||||
"city": "Sacramento",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"utc_offset": -8
|
||||
},
|
||||
"SAS": {
|
||||
"arena": "Frost Bank Center",
|
||||
"city": "San Antonio",
|
||||
"timezone": "America/Chicago",
|
||||
"utc_offset": -6
|
||||
},
|
||||
"TOR": {
|
||||
"arena": "Scotiabank Arena",
|
||||
"city": "Toronto",
|
||||
"timezone": "America/Toronto",
|
||||
"utc_offset": -5
|
||||
},
|
||||
"UTA": {
|
||||
"arena": "Delta Center",
|
||||
"city": "Salt Lake City",
|
||||
"timezone": "America/Denver",
|
||||
"utc_offset": -7
|
||||
},
|
||||
"WAS": {
|
||||
"arena": "Capital One Arena",
|
||||
"city": "Washington",
|
||||
"timezone": "America/New_York",
|
||||
"utc_offset": -5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
VYNDR Evolution Engine — Python Microservice
|
||||
PELT changepoint detection for player metric evolution.
|
||||
Port 5001.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Graceful import — ruptures may not be installed
|
||||
try:
|
||||
import ruptures as rpt
|
||||
HAS_RUPTURES = True
|
||||
except ImportError:
|
||||
HAS_RUPTURES = False
|
||||
print("[evolution-engine] WARNING: ruptures not installed. Using fallback.", file=sys.stderr)
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def detect_changepoints_pelt(values, min_size=5, penalty=3.0):
|
||||
"""Use PELT algorithm from ruptures library."""
|
||||
if not HAS_RUPTURES:
|
||||
return fallback_detect(values)
|
||||
|
||||
signal = np.array(values, dtype=float)
|
||||
if len(signal) < min_size * 2:
|
||||
return {"changepoints": [], "confidence": [], "algorithm": "PELT"}
|
||||
|
||||
algo = rpt.Pelt(model="rbf", min_size=min_size).fit(signal)
|
||||
result = algo.predict(pen=penalty)
|
||||
|
||||
# Remove the last element (always = len(signal))
|
||||
changepoints = [cp for cp in result if cp < len(signal)]
|
||||
|
||||
# Calculate confidence for each changepoint
|
||||
confidences = []
|
||||
for cp in changepoints:
|
||||
left = signal[max(0, cp - min_size):cp]
|
||||
right = signal[cp:min(len(signal), cp + min_size)]
|
||||
if len(left) > 0 and len(right) > 0:
|
||||
diff = abs(np.mean(right) - np.mean(left))
|
||||
std = max(np.std(signal), 0.01)
|
||||
conf = min(diff / std, 1.0)
|
||||
confidences.append(round(conf, 3))
|
||||
else:
|
||||
confidences.append(0.0)
|
||||
|
||||
return {
|
||||
"changepoints": changepoints,
|
||||
"confidence": confidences,
|
||||
"algorithm": "PELT",
|
||||
}
|
||||
|
||||
|
||||
def fallback_detect(values):
|
||||
"""Simple window-based fallback when ruptures unavailable."""
|
||||
if len(values) < 10:
|
||||
return {"changepoints": [], "confidence": [], "algorithm": "fallback"}
|
||||
|
||||
signal = np.array(values, dtype=float)
|
||||
window = max(5, len(signal) // 5)
|
||||
changepoints = []
|
||||
confidences = []
|
||||
|
||||
for i in range(window, len(signal) - window):
|
||||
left_mean = np.mean(signal[i - window:i])
|
||||
right_mean = np.mean(signal[i:i + window])
|
||||
std = max(np.std(signal), 0.01)
|
||||
diff = abs(right_mean - left_mean)
|
||||
if diff / std > 1.5:
|
||||
changepoints.append(i)
|
||||
confidences.append(min(round(diff / std / 3.0, 3), 1.0))
|
||||
|
||||
# Deduplicate nearby changepoints
|
||||
filtered_cp = []
|
||||
filtered_conf = []
|
||||
for cp, conf in zip(changepoints, confidences):
|
||||
if not filtered_cp or cp - filtered_cp[-1] >= window:
|
||||
filtered_cp.append(cp)
|
||||
filtered_conf.append(conf)
|
||||
|
||||
return {
|
||||
"changepoints": filtered_cp,
|
||||
"confidence": filtered_conf,
|
||||
"algorithm": "fallback",
|
||||
}
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"ruptures_available": HAS_RUPTURES,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/detect-changepoints", methods=["POST"])
|
||||
def detect_changepoints():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
values = data.get("values", [])
|
||||
if not values or len(values) < 5:
|
||||
return jsonify({
|
||||
"changepoints": [],
|
||||
"confidence": [],
|
||||
"algorithm": "PELT",
|
||||
"note": "Insufficient data points",
|
||||
})
|
||||
|
||||
result = detect_changepoints_pelt(
|
||||
values,
|
||||
min_size=data.get("min_size", 5),
|
||||
penalty=data.get("penalty", 3.0),
|
||||
)
|
||||
result["player_id"] = data.get("player_id")
|
||||
result["metric"] = data.get("metric")
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("[evolution-engine] Starting on port 5001...")
|
||||
app.run(host="0.0.0.0", port=5001, debug=False)
|
||||
@@ -0,0 +1,16 @@
|
||||
flask>=3.0
|
||||
flask-limiter>=3.5
|
||||
flask-cors>=4.0
|
||||
numpy>=1.24
|
||||
pandas>=2.0
|
||||
scipy>=1.11
|
||||
nba_api>=1.4
|
||||
pybaseball>=2.2
|
||||
redis>=5.0
|
||||
requests>=2.31
|
||||
ruptures>=1.1
|
||||
pytesseract>=0.3
|
||||
Pillow>=10.0
|
||||
MLB-StatsAPI>=1.7
|
||||
supabase>=2.0
|
||||
PyJWT>=2.8
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
VYNDR Multi-Dimensional Archetype System
|
||||
Pitcher, batter, and NBA player archetype detection and weight blending.
|
||||
ALL dimensions have weight_profiles — without them blending returns defaults.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# ============================================================
|
||||
# MLB PITCHER DIMENSIONS
|
||||
# ============================================================
|
||||
|
||||
PITCHER_DIMENSIONS = {
|
||||
'power': {
|
||||
'detect': lambda p: min(1.0, max(0, (p.get('fb_velo_season', 91) - 91) / 6)),
|
||||
'weight_profile': {
|
||||
'velocity_trend': 0.40, 'command_trend': 0.15,
|
||||
'whiff_trend': 0.25, 'pitch_mix_shift': 0.10, 'workload': 0.10
|
||||
}
|
||||
},
|
||||
'finesse': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('zone_pct_season', 0.42) - 0.42) / 0.10)) *
|
||||
min(1.0, max(0, (94 - p.get('fb_velo_season', 94)) / 4))
|
||||
),
|
||||
'weight_profile': {
|
||||
'velocity_trend': 0.10, 'command_trend': 0.40,
|
||||
'whiff_trend': 0.15, 'pitch_mix_shift': 0.25, 'workload': 0.10
|
||||
}
|
||||
},
|
||||
'groundball': {
|
||||
'detect': lambda p: min(1.0, max(0, (p.get('gb_rate_season', 0.40) - 0.40) / 0.15)),
|
||||
'weight_profile': {
|
||||
'velocity_trend': 0.20, 'command_trend': 0.30,
|
||||
'whiff_trend': 0.10, 'pitch_mix_shift': 0.25, 'workload': 0.15
|
||||
}
|
||||
},
|
||||
'strikeout_artist': {
|
||||
'detect': lambda p: min(1.0, max(0, (p.get('k_rate_season', 0.20) - 0.20) / 0.12)),
|
||||
'weight_profile': {
|
||||
'velocity_trend': 0.25, 'command_trend': 0.15,
|
||||
'whiff_trend': 0.35, 'pitch_mix_shift': 0.15, 'workload': 0.10
|
||||
}
|
||||
},
|
||||
'workhorse': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('ip_per_start', 5) - 5.0) / 2.0)) *
|
||||
min(1.0, max(0, (18 - p.get('pitches_per_ip', 17)) / 4))
|
||||
),
|
||||
'weight_profile': {
|
||||
'velocity_trend': 0.20, 'command_trend': 0.25,
|
||||
'whiff_trend': 0.15, 'pitch_mix_shift': 0.15, 'workload': 0.25
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_PTI_WEIGHTS = {
|
||||
'velocity_trend': 0.30, 'command_trend': 0.25,
|
||||
'whiff_trend': 0.20, 'pitch_mix_shift': 0.15, 'workload': 0.10
|
||||
}
|
||||
|
||||
# Pitcher identity tags (binary)
|
||||
PITCHER_IDENTITY = {
|
||||
'putaway_specialist': lambda p: max(p.get('whiff_rates_by_pitch', {}).values(), default=0) > 0.35,
|
||||
'pitch_to_contact': lambda p: p.get('k_rate_season', 0.22) < 0.18 and p.get('bb_rate_season', 0.08) < 0.06,
|
||||
'max_effort': lambda p: p.get('velo_decay_after_60', 0) > 1.5,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# MLB BATTER DIMENSIONS
|
||||
# ============================================================
|
||||
|
||||
BATTER_DIMENSIONS = {
|
||||
'power': {
|
||||
'detect': lambda b: min(1.0, max(0, (b.get('avg_exit_velo', 87) - 87) / 6)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'platoon_advantage': 0.15,
|
||||
'pitcher_matchup': 0.25, 'park_factor': 0.25, 'lineup_position': 0.15
|
||||
}
|
||||
},
|
||||
'contact': {
|
||||
'detect': lambda b: min(1.0, max(0, (0.25 - b.get('k_rate_season', 0.22)) / 0.12)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.30, 'platoon_advantage': 0.25,
|
||||
'pitcher_matchup': 0.15, 'park_factor': 0.10, 'lineup_position': 0.20
|
||||
}
|
||||
},
|
||||
'speed': {
|
||||
'detect': lambda b: min(1.0, max(0, (b.get('sprint_speed', 26) - 26) / 4)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.25, 'platoon_advantage': 0.15,
|
||||
'pitcher_matchup': 0.15, 'park_factor': 0.10, 'lineup_position': 0.35
|
||||
}
|
||||
},
|
||||
'run_producer': {
|
||||
'detect': lambda b: (
|
||||
min(1.0, max(0, (b.get('rbi_per_game', 0) - 0.4) / 0.6)) *
|
||||
(1.0 if b.get('lineup_position', 9) in [3, 4, 5] else 0.4)
|
||||
),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'platoon_advantage': 0.20,
|
||||
'pitcher_matchup': 0.20, 'park_factor': 0.15, 'lineup_position': 0.25
|
||||
}
|
||||
},
|
||||
'damage_dealer': {
|
||||
'detect': lambda b: min(1.0, max(0, (b.get('iso', 0.140) - 0.140) / 0.120)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'platoon_advantage': 0.15,
|
||||
'pitcher_matchup': 0.20, 'park_factor': 0.30, 'lineup_position': 0.15
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_BCS_WEIGHTS = {
|
||||
'recent_form': 0.25, 'platoon_advantage': 0.25,
|
||||
'pitcher_matchup': 0.20, 'park_factor': 0.15, 'lineup_position': 0.15
|
||||
}
|
||||
|
||||
# Batter approach tags (binary)
|
||||
BATTER_APPROACH = {
|
||||
'fastball_hunter': lambda b: b.get('fb_whiff_rate', 0.20) < 0.15 and b.get('fb_slg', 0.400) > 0.500,
|
||||
'count_worker': lambda b: b.get('bb_rate_season', 0) > 0.10 and b.get('pitches_per_pa', 3.5) > 4.0,
|
||||
'first_pitch_aggressive': lambda b: b.get('first_pitch_swing_rate', 0.25) > 0.35,
|
||||
'spray_hitter': lambda b: b.get('oppo_pct', 0.20) > 0.25 and b.get('pull_pct', 0.40) < 0.42,
|
||||
'situational': lambda b: abs(b.get('risp_ops', 0.750) - b.get('overall_ops', 0.750)) > 0.080,
|
||||
}
|
||||
|
||||
# Batting order context
|
||||
BATTING_ORDER = {
|
||||
1: {'pa_mult': 1.10, 'rbi_ctx': 'low', 'pitch_quality': 'high_fb'},
|
||||
2: {'pa_mult': 1.08, 'rbi_ctx': 'moderate', 'pitch_quality': 'high'},
|
||||
3: {'pa_mult': 1.05, 'rbi_ctx': 'high', 'pitch_quality': 'mixed'},
|
||||
4: {'pa_mult': 1.03, 'rbi_ctx': 'highest', 'pitch_quality': 'mixed'},
|
||||
5: {'pa_mult': 1.00, 'rbi_ctx': 'high', 'pitch_quality': 'moderate'},
|
||||
6: {'pa_mult': 0.97, 'rbi_ctx': 'moderate', 'pitch_quality': 'moderate'},
|
||||
7: {'pa_mult': 0.94, 'rbi_ctx': 'low', 'pitch_quality': 'lower'},
|
||||
8: {'pa_mult': 0.91, 'rbi_ctx': 'low', 'pitch_quality': 'lower'},
|
||||
9: {'pa_mult': 0.88, 'rbi_ctx': 'lowest', 'pitch_quality': 'varies'}
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# NBA DIMENSIONS — ALL with weight_profiles
|
||||
# ============================================================
|
||||
|
||||
NBA_SUB_SCORES = [
|
||||
'recent_form', 'matchup_defense', 'pace_factor',
|
||||
'usage_context', 'home_road', 'rest_travel'
|
||||
]
|
||||
|
||||
DEFAULT_NBA_WEIGHTS = {
|
||||
'recent_form': 0.25, 'matchup_defense': 0.20, 'pace_factor': 0.15,
|
||||
'usage_context': 0.20, 'home_road': 0.10, 'rest_travel': 0.10
|
||||
}
|
||||
|
||||
NBA_DIMENSIONS = {
|
||||
'primary_scorer': {
|
||||
'detect': lambda p: min(1.0, max(0, (p.get('usage_rate', 0.20) - 0.22) / 0.12)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.25, 'matchup_defense': 0.30, 'pace_factor': 0.10,
|
||||
'usage_context': 0.15, 'home_road': 0.10, 'rest_travel': 0.10
|
||||
}
|
||||
},
|
||||
'primary_playmaker': {
|
||||
'detect': lambda p: min(1.0, max(0, (p.get('assist_rate', 0.15) - 0.20) / 0.18)),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'matchup_defense': 0.15, 'pace_factor': 0.20,
|
||||
'usage_context': 0.30, 'home_road': 0.05, 'rest_travel': 0.10
|
||||
}
|
||||
},
|
||||
'three_and_d': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('three_pa_rate', 0.30) - 0.35) / 0.25)) *
|
||||
min(1.0, max(0, (0.25 - p.get('usage_rate', 0.20)) / 0.08))
|
||||
),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.30, 'matchup_defense': 0.15, 'pace_factor': 0.15,
|
||||
'usage_context': 0.25, 'home_road': 0.10, 'rest_travel': 0.05
|
||||
}
|
||||
},
|
||||
'interior_big': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('fg_pct', 0.45) - 0.50) / 0.15)) *
|
||||
min(1.0, max(0, (p.get('reb_per_game', 4) - 5) / 6))
|
||||
),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'matchup_defense': 0.25, 'pace_factor': 0.20,
|
||||
'usage_context': 0.15, 'home_road': 0.10, 'rest_travel': 0.10
|
||||
}
|
||||
},
|
||||
'secondary_creator': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('usage_rate', 0.20) - 0.18) / 0.10)) *
|
||||
(1 - min(1.0, max(0, (p.get('usage_rate', 0.20) - 0.28) / 0.05)))
|
||||
),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.20, 'matchup_defense': 0.15, 'pace_factor': 0.15,
|
||||
'usage_context': 0.35, 'home_road': 0.05, 'rest_travel': 0.10
|
||||
}
|
||||
},
|
||||
'stretch_big': {
|
||||
'detect': lambda p: (
|
||||
min(1.0, max(0, (p.get('reb_per_game', 0) - 5) / 6)) *
|
||||
min(1.0, max(0, (p.get('three_pa_rate', 0) - 0.15) / 0.20))
|
||||
),
|
||||
'weight_profile': {
|
||||
'recent_form': 0.25, 'matchup_defense': 0.20, 'pace_factor': 0.20,
|
||||
'usage_context': 0.15, 'home_road': 0.10, 'rest_travel': 0.10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# WEIGHT BLENDING
|
||||
# ============================================================
|
||||
|
||||
|
||||
def get_archetype_scores(profile, dimensions):
|
||||
"""
|
||||
Calculate archetype scores for a player profile.
|
||||
|
||||
Args:
|
||||
profile: Dict of player stats/attributes.
|
||||
dimensions: Dict of dimension definitions (e.g., NBA_DIMENSIONS).
|
||||
|
||||
Returns:
|
||||
Dict mapping dimension name to detection score (0.0-1.0).
|
||||
"""
|
||||
scores = {}
|
||||
for name, dim in dimensions.items():
|
||||
try:
|
||||
scores[name] = dim['detect'](profile)
|
||||
except (KeyError, TypeError, ZeroDivisionError):
|
||||
scores[name] = 0.0
|
||||
return scores
|
||||
|
||||
|
||||
def blend_archetype_weights(profile, dimensions, defaults):
|
||||
"""
|
||||
Blend weight profiles based on archetype detection scores.
|
||||
Returns default weights when all archetype scores are below threshold.
|
||||
|
||||
Args:
|
||||
profile: Dict of player stats/attributes.
|
||||
dimensions: Dict of dimension definitions.
|
||||
defaults: Dict of default weights (fallback).
|
||||
|
||||
Returns:
|
||||
Dict of blended weights, proportional to archetype detection scores.
|
||||
"""
|
||||
scores = get_archetype_scores(profile, dimensions)
|
||||
total = sum(scores.values())
|
||||
|
||||
if total < 0.1:
|
||||
return defaults.copy()
|
||||
|
||||
# Get all weight keys from first dimension's weight_profile
|
||||
weight_keys = list(list(dimensions.values())[0].get('weight_profile', defaults).keys())
|
||||
blended = {}
|
||||
|
||||
for wk in weight_keys:
|
||||
blended[wk] = sum(
|
||||
scores[name] * dim.get('weight_profile', defaults).get(wk, 0)
|
||||
for name, dim in dimensions.items()
|
||||
) / total
|
||||
|
||||
return blended
|
||||
|
||||
|
||||
def get_batting_order_context(position):
|
||||
"""
|
||||
Get batting order context for a lineup position.
|
||||
|
||||
Args:
|
||||
position: Integer lineup position (1-9).
|
||||
|
||||
Returns:
|
||||
Dict with pa_mult, rbi_ctx, pitch_quality.
|
||||
"""
|
||||
return BATTING_ORDER.get(position, BATTING_ORDER[9])
|
||||
|
||||
|
||||
def detect_batter_approach(batter_profile):
|
||||
"""
|
||||
Detect batter approach tags (binary classifications).
|
||||
|
||||
Args:
|
||||
batter_profile: Dict of batter stats.
|
||||
|
||||
Returns:
|
||||
Dict mapping approach tag to bool.
|
||||
"""
|
||||
result = {}
|
||||
for tag, detect_fn in BATTER_APPROACH.items():
|
||||
try:
|
||||
result[tag] = detect_fn(batter_profile)
|
||||
except (KeyError, TypeError):
|
||||
result[tag] = False
|
||||
return result
|
||||
|
||||
|
||||
def detect_pitcher_identity(pitcher_profile):
|
||||
"""
|
||||
Detect pitcher identity tags (binary classifications).
|
||||
|
||||
Args:
|
||||
pitcher_profile: Dict of pitcher stats.
|
||||
|
||||
Returns:
|
||||
Dict mapping identity tag to bool.
|
||||
"""
|
||||
result = {}
|
||||
for tag, detect_fn in PITCHER_IDENTITY.items():
|
||||
try:
|
||||
result[tag] = detect_fn(pitcher_profile)
|
||||
except (KeyError, TypeError):
|
||||
result[tag] = False
|
||||
return result
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
VYNDR Authentication Middleware
|
||||
Verifies Supabase JWT tokens on all protected endpoints.
|
||||
Internal key validation for cron/service endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import functools
|
||||
from flask import request, jsonify
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
SUPABASE_JWT_SECRET = os.environ.get('SUPABASE_JWT_SECRET', '')
|
||||
SUPABASE_URL = os.environ.get('SUPABASE_URL', '')
|
||||
|
||||
# Service-role key. Read VYNDR_INTERNAL_KEY first, fall back to the legacy
|
||||
# BETONBLK_INTERNAL_KEY so deployed Railway secrets keep working until the
|
||||
# operator renames the env var. Both names accepted during the transition.
|
||||
INTERNAL_KEY = os.environ.get('VYNDR_INTERNAL_KEY') or os.environ.get('BETONBLK_INTERNAL_KEY', '')
|
||||
|
||||
|
||||
def verify_jwt(token):
|
||||
"""
|
||||
Verify a Supabase JWT token with issuer check.
|
||||
|
||||
Args:
|
||||
token: JWT token string.
|
||||
|
||||
Returns:
|
||||
Decoded payload dict if valid, None if invalid.
|
||||
"""
|
||||
if not SUPABASE_JWT_SECRET:
|
||||
logger.warning('[Auth] JWT secret not configured — skipping verification')
|
||||
return {'sub': 'anonymous', 'role': 'authenticated'}
|
||||
|
||||
try:
|
||||
import jwt
|
||||
kwargs = {
|
||||
'algorithms': ['HS256'],
|
||||
'audience': 'authenticated',
|
||||
}
|
||||
# Issuer check prevents cross-project token reuse
|
||||
if SUPABASE_URL:
|
||||
kwargs['issuer'] = SUPABASE_URL
|
||||
|
||||
decoded = jwt.decode(token, SUPABASE_JWT_SECRET, **kwargs)
|
||||
return decoded
|
||||
except Exception as e:
|
||||
if 'ExpiredSignature' in type(e).__name__:
|
||||
logger.warning('[Auth] Expired token')
|
||||
else:
|
||||
logger.warning(f'[Auth] Invalid token: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""
|
||||
Decorator for user-facing endpoints.
|
||||
Extracts Bearer token from Authorization header.
|
||||
Attaches user info to Flask request context.
|
||||
"""
|
||||
@functools.wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
||||
|
||||
token = auth_header[7:] # Strip 'Bearer '
|
||||
if not token:
|
||||
return jsonify({'error': 'Empty token'}), 401
|
||||
|
||||
payload = verify_jwt(token)
|
||||
if not payload:
|
||||
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||
|
||||
request.user_id = payload.get('sub')
|
||||
request.user_role = payload.get('role', 'authenticated')
|
||||
request.user_email = payload.get('email', '')
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def require_service_role(f):
|
||||
"""
|
||||
Decorator for internal/cron endpoints.
|
||||
Validates the service-role internal key (read from VYNDR_INTERNAL_KEY,
|
||||
falling back to BETONBLK_INTERNAL_KEY during the env-var rename).
|
||||
The service key never leaves Railway. GitHub Actions crons use the
|
||||
internal key only.
|
||||
"""
|
||||
@functools.wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
api_key = request.headers.get('X-API-Key', '')
|
||||
|
||||
if INTERNAL_KEY and api_key == INTERNAL_KEY:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Fallback: check Authorization Bearer against internal key
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer ') and INTERNAL_KEY:
|
||||
if auth_header[7:] == INTERNAL_KEY:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return jsonify({'error': 'Unauthorized — service role required'}), 403
|
||||
return decorated
|
||||
|
||||
|
||||
def get_real_ip():
|
||||
"""
|
||||
Get real client IP accounting for Railway/proxy X-Forwarded-For header.
|
||||
|
||||
Returns:
|
||||
Client IP string.
|
||||
"""
|
||||
forwarded = request.headers.get('X-Forwarded-For', '')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or '127.0.0.1'
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
VYNDR Bayesian Distribution Engine
|
||||
Shared by NBA and MLB. Per-stat-type weights. Similar game confidence modifier.
|
||||
Skewness parameter. Data sufficiency smooth degradation curve.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# INITIAL ESTIMATES — recalculate after 500+ resolved grades per stat type
|
||||
# using grid search on historical Brier scores. Store optimized weights in global_calibration.
|
||||
BAYESIAN_WEIGHTS = {
|
||||
'strikeouts': {'prior': 0.40, 'recent': 0.40, 'context': 0.20},
|
||||
'hits': {'prior': 0.30, 'recent': 0.45, 'context': 0.25},
|
||||
'rbi': {'prior': 0.25, 'recent': 0.45, 'context': 0.30},
|
||||
'home_runs': {'prior': 0.30, 'recent': 0.35, 'context': 0.35},
|
||||
'total_bases': {'prior': 0.30, 'recent': 0.40, 'context': 0.30},
|
||||
'walks': {'prior': 0.35, 'recent': 0.40, 'context': 0.25},
|
||||
'points': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
|
||||
'rebounds': {'prior': 0.40, 'recent': 0.40, 'context': 0.20},
|
||||
'assists': {'prior': 0.30, 'recent': 0.50, 'context': 0.20},
|
||||
'threes': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
|
||||
'pts_reb_ast': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
|
||||
'default': {'prior': 0.35, 'recent': 0.45, 'context': 0.20}
|
||||
}
|
||||
|
||||
# Grade scale — LOCKED
|
||||
GRADE_THRESHOLDS = {
|
||||
'A+': (0.85, 1.00),
|
||||
'A': (0.78, 0.84),
|
||||
'A-': (0.72, 0.77),
|
||||
'B+': (0.66, 0.71),
|
||||
'B': (0.60, 0.65),
|
||||
'B-': (0.55, 0.59),
|
||||
'C+': (0.50, 0.54),
|
||||
'C': (0.45, 0.49),
|
||||
'C-': (0.40, 0.44),
|
||||
'D': (0.30, 0.39),
|
||||
'F': (0.00, 0.29)
|
||||
}
|
||||
|
||||
ABSTENTION_RULES = {
|
||||
'confidence_range': (0.40, 0.55),
|
||||
'similar_games_below': 3,
|
||||
'data_quality_limited': True
|
||||
}
|
||||
|
||||
MIN_DATA_THRESHOLDS = {
|
||||
'mlb_pitcher': {'min_starts': 3, 'min_pitches': 200},
|
||||
'mlb_batter': {'min_pa': 50, 'min_games': 12},
|
||||
'nba_player': {'min_games': 8, 'min_minutes_per_game': 15}
|
||||
}
|
||||
|
||||
CALIBRATION_DISCLAIMER = (
|
||||
"Model in calibration period. Confidence levels are estimated, not validated. "
|
||||
"Track record begins building now."
|
||||
)
|
||||
|
||||
SHADOW_MODE = True # Set to False after 2 weeks of verified accuracy
|
||||
|
||||
|
||||
def norm_cdf(x, mean, std):
|
||||
"""
|
||||
Standard normal CDF using error function.
|
||||
|
||||
Args:
|
||||
x: Value to evaluate.
|
||||
mean: Distribution mean.
|
||||
std: Distribution standard deviation.
|
||||
|
||||
Returns:
|
||||
Cumulative probability P(X <= x).
|
||||
"""
|
||||
if std <= 0:
|
||||
return 1.0 if x <= mean else 0.0
|
||||
z = (x - mean) / std
|
||||
return 0.5 * (1 + float(np.erf(z / np.sqrt(2))))
|
||||
|
||||
|
||||
def similar_game_confidence_modifier(count):
|
||||
"""
|
||||
Adjust confidence based on historical similar game depth.
|
||||
|
||||
Args:
|
||||
count: Number of similar games found.
|
||||
|
||||
Returns:
|
||||
Float adjustment to confidence (positive = boost, negative = penalty).
|
||||
"""
|
||||
if count >= 10:
|
||||
return 0.05
|
||||
elif count >= 5:
|
||||
return 0.02
|
||||
elif count <= 1:
|
||||
return -0.03
|
||||
return 0.0
|
||||
|
||||
|
||||
def calculate_bayesian_projection(prior_mean, prior_std, recent_mean, recent_std,
|
||||
context_adjustment, line, over_under,
|
||||
stat_type='default', similar_game_count=0):
|
||||
"""
|
||||
Produce a posterior distribution for a stat projection.
|
||||
|
||||
Uses per-stat-type Bayesian weights to blend prior (season baseline),
|
||||
recent (last N games), and context (matchup/park/weather adjustments).
|
||||
|
||||
Args:
|
||||
prior_mean: Season average for the stat.
|
||||
prior_std: Season standard deviation.
|
||||
recent_mean: Recent game average (last N games).
|
||||
recent_std: Recent game standard deviation.
|
||||
context_adjustment: Aggregate contextual adjustment value.
|
||||
line: Prop line to evaluate against.
|
||||
over_under: 'over' or 'under'.
|
||||
stat_type: Stat type key for weight lookup (default='default').
|
||||
similar_game_count: Number of similar historical games found.
|
||||
|
||||
Returns:
|
||||
Dict with projected_value, projected_std, prob_clear_line, confidence,
|
||||
similar_game_modifier, bayesian_weights_used, and distribution details.
|
||||
"""
|
||||
weights = BAYESIAN_WEIGHTS.get(stat_type, BAYESIAN_WEIGHTS['default'])
|
||||
w_prior = weights['prior']
|
||||
w_recent = weights['recent']
|
||||
w_context = weights['context']
|
||||
|
||||
posterior_mean = (
|
||||
prior_mean * w_prior +
|
||||
recent_mean * w_recent +
|
||||
(prior_mean + context_adjustment) * w_context
|
||||
)
|
||||
posterior_std = np.sqrt(
|
||||
(prior_std ** 2 * w_prior + recent_std ** 2 * w_recent) /
|
||||
(w_prior + w_recent)
|
||||
)
|
||||
|
||||
# Ensure std is positive
|
||||
posterior_std = max(posterior_std, 0.01)
|
||||
|
||||
if over_under == 'over':
|
||||
prob = 1 - norm_cdf(line, posterior_mean, posterior_std)
|
||||
else:
|
||||
prob = norm_cdf(line, posterior_mean, posterior_std)
|
||||
|
||||
# Similar game confidence modifier
|
||||
sim_modifier = similar_game_confidence_modifier(similar_game_count)
|
||||
prob = max(0.0, min(1.0, prob + sim_modifier))
|
||||
|
||||
return {
|
||||
'projected_value': round(float(posterior_mean), 1),
|
||||
'projected_std': round(float(posterior_std), 2),
|
||||
'prob_clear_line': round(float(prob), 3),
|
||||
'confidence': round(float(prob), 3),
|
||||
'similar_game_modifier': sim_modifier,
|
||||
'bayesian_weights_used': weights,
|
||||
'distribution': {
|
||||
'mean': float(posterior_mean),
|
||||
'std': float(posterior_std),
|
||||
'p10': round(float(posterior_mean - 1.28 * posterior_std), 1),
|
||||
'p90': round(float(posterior_mean + 1.28 * posterior_std), 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def calculate_skewness(game_log_values):
|
||||
"""
|
||||
Measure skew of a player's performance distribution.
|
||||
Positive skew = occasional blowup games (favors alt line overs).
|
||||
Negative skew = consistent, capped upside (favors standard line overs).
|
||||
|
||||
Args:
|
||||
game_log_values: List of numeric stat values from game log.
|
||||
|
||||
Returns:
|
||||
Float skewness value. Returns 0.0 if insufficient data (<10 games).
|
||||
"""
|
||||
if len(game_log_values) < 10:
|
||||
return 0.0
|
||||
try:
|
||||
from scipy.stats import skew
|
||||
return round(float(skew(game_log_values)), 2)
|
||||
except ImportError:
|
||||
# Manual skewness calculation as fallback
|
||||
arr = np.array(game_log_values, dtype=float)
|
||||
n = len(arr)
|
||||
mean = np.mean(arr)
|
||||
std = np.std(arr, ddof=1)
|
||||
if std == 0:
|
||||
return 0.0
|
||||
return round(float((n / ((n - 1) * (n - 2))) * np.sum(((arr - mean) / std) ** 3)), 2)
|
||||
|
||||
|
||||
def apply_data_sufficiency_modifier(confidence, games_played, min_games):
|
||||
"""
|
||||
Smooth confidence degradation near minimum threshold.
|
||||
No hard cliff at min_games — gradual ramp from 70% to 100% of confidence.
|
||||
Full confidence at 2x minimum games.
|
||||
|
||||
Args:
|
||||
confidence: Raw confidence score.
|
||||
games_played: Number of games the player has played this season.
|
||||
min_games: Minimum games required for full confidence.
|
||||
|
||||
Returns:
|
||||
Adjusted confidence score.
|
||||
"""
|
||||
if games_played < min_games:
|
||||
return min(confidence, 0.54) # Below minimum = C+ cap
|
||||
|
||||
ramp = min(1.0, 0.70 + 0.30 * ((games_played - min_games) / max(min_games, 1)))
|
||||
return confidence * ramp
|
||||
|
||||
|
||||
def should_abstain(confidence, similar_game_count, data_quality):
|
||||
"""
|
||||
Determine if the model should abstain from grading.
|
||||
A C grade that misses damages credibility more than no grade at all.
|
||||
|
||||
Args:
|
||||
confidence: Calculated confidence score.
|
||||
similar_game_count: Number of similar historical games found.
|
||||
data_quality: 'full', 'limited', or 'minimal'.
|
||||
|
||||
Returns:
|
||||
True if model should abstain, False if grade should be published.
|
||||
"""
|
||||
low, high = ABSTENTION_RULES['confidence_range']
|
||||
if low <= confidence <= high and similar_game_count < ABSTENTION_RULES['similar_games_below']:
|
||||
return True
|
||||
if data_quality == 'limited' and confidence < 0.55:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def score_to_grade(score, global_offset=0.0):
|
||||
"""
|
||||
Map a confidence score to a letter grade.
|
||||
|
||||
Args:
|
||||
score: Raw confidence score (0.0 to 1.0).
|
||||
global_offset: Calibration adjustment from grade_outcomes analysis.
|
||||
Applied BEFORE grade mapping. Starts at 0.0, updated monthly
|
||||
after 100+ resolved grades.
|
||||
|
||||
Returns:
|
||||
Grade string (A+ through F).
|
||||
"""
|
||||
adjusted_score = max(0.0, min(1.0, score + global_offset))
|
||||
for grade, (low, high) in GRADE_THRESHOLDS.items():
|
||||
if low <= adjusted_score <= high:
|
||||
return grade
|
||||
return 'F'
|
||||
|
||||
|
||||
def calculate_global_offset(resolved_outcomes, min_resolved=100):
|
||||
"""
|
||||
Calculate global calibration offset from resolved grade outcomes.
|
||||
Clamped to ±0.15 to prevent overcorrection.
|
||||
|
||||
Args:
|
||||
resolved_outcomes: List of dicts with 'confidence' and 'hit' keys.
|
||||
min_resolved: Minimum resolved grades before calculating offset.
|
||||
|
||||
Returns:
|
||||
Float offset value, clamped between -0.15 and 0.15.
|
||||
"""
|
||||
if len(resolved_outcomes) < min_resolved:
|
||||
return 0.0
|
||||
|
||||
grade_accuracy = {}
|
||||
for grade_name, (low, high) in GRADE_THRESHOLDS.items():
|
||||
grade_outcomes = [o for o in resolved_outcomes if low <= o['confidence'] <= high]
|
||||
if len(grade_outcomes) >= 10:
|
||||
hit_rate = sum(1 for o in grade_outcomes if o['hit']) / len(grade_outcomes)
|
||||
expected_midpoint = (low + high) / 2
|
||||
grade_accuracy[grade_name] = hit_rate - expected_midpoint
|
||||
|
||||
if not grade_accuracy:
|
||||
return 0.0
|
||||
|
||||
avg_drift = sum(grade_accuracy.values()) / len(grade_accuracy)
|
||||
return max(-0.15, min(0.15, avg_drift))
|
||||
|
||||
|
||||
def calculate_brier_score(resolved_grades):
|
||||
"""
|
||||
Brier score = mean((predicted_probability - actual_outcome)^2).
|
||||
Lower is better. 0.0 = perfect. 0.25 = coin flip.
|
||||
|
||||
Args:
|
||||
resolved_grades: List of dicts with 'confidence' and 'hit' keys.
|
||||
|
||||
Returns:
|
||||
Float Brier score, or None if no data.
|
||||
"""
|
||||
if not resolved_grades:
|
||||
return None
|
||||
total = sum(
|
||||
(g['confidence'] - (1.0 if g['hit'] else 0.0)) ** 2
|
||||
for g in resolved_grades
|
||||
)
|
||||
return round(total / len(resolved_grades), 4)
|
||||
|
||||
|
||||
def get_disclaimer(resolved_count):
|
||||
"""
|
||||
Return calibration disclaimer if model is still in calibration period.
|
||||
|
||||
Args:
|
||||
resolved_count: Number of resolved grades for the sport.
|
||||
|
||||
Returns:
|
||||
Disclaimer string, or None if past calibration period.
|
||||
"""
|
||||
if resolved_count < 100:
|
||||
return CALIBRATION_DISCLAIMER
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
VYNDR Blind Spot Detector
|
||||
Identifies conditions where the model underperforms.
|
||||
Tracks catastrophic misses (worst 5%).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from utils.bayesian import calculate_brier_score
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# Conditions to check for blind spots
|
||||
BLIND_SPOT_CONDITIONS = [
|
||||
'home', 'road', 'day_game', 'night_game', 'back_to_back',
|
||||
'division_game', 'interleague', 'high_altitude', 'dome_game'
|
||||
]
|
||||
|
||||
MIN_SAMPLE_FOR_BLIND_SPOT = 30
|
||||
DEGRADATION_THRESHOLD = 0.25 # 25% worse than overall
|
||||
|
||||
|
||||
def detect_model_blind_spots(all_outcomes, min_sample=None):
|
||||
"""
|
||||
Find conditions where the model's Brier score is 25%+ worse
|
||||
than its overall Brier score. These are the blind spots.
|
||||
|
||||
Args:
|
||||
all_outcomes: List of resolved outcome dicts. Each must have:
|
||||
'confidence' (float), 'hit' (bool), 'context' (dict of condition flags).
|
||||
min_sample: Minimum sample size per condition (default 30).
|
||||
|
||||
Returns:
|
||||
List of blind spot dicts with condition, brier_score, overall_brier,
|
||||
degradation, and sample_size.
|
||||
"""
|
||||
if min_sample is None:
|
||||
min_sample = MIN_SAMPLE_FOR_BLIND_SPOT
|
||||
|
||||
overall_brier = calculate_brier_score(all_outcomes)
|
||||
if overall_brier is None or overall_brier == 0:
|
||||
return []
|
||||
|
||||
blind_spots = []
|
||||
for condition in BLIND_SPOT_CONDITIONS:
|
||||
subset = [
|
||||
o for o in all_outcomes
|
||||
if o.get('context', {}).get(condition)
|
||||
]
|
||||
if len(subset) >= min_sample:
|
||||
subset_brier = calculate_brier_score(subset)
|
||||
if subset_brier is not None and subset_brier > overall_brier * (1 + DEGRADATION_THRESHOLD):
|
||||
blind_spots.append({
|
||||
'condition': condition,
|
||||
'brier_score': subset_brier,
|
||||
'overall_brier': overall_brier,
|
||||
'degradation': round((subset_brier - overall_brier) / overall_brier, 2),
|
||||
'sample_size': len(subset)
|
||||
})
|
||||
|
||||
return blind_spots
|
||||
|
||||
|
||||
def track_catastrophic_misses(all_outcomes, percentile=0.05):
|
||||
"""
|
||||
Track the WORST misses specifically — not just average performance.
|
||||
An A+ grade that misses by 15 points is a reputational disaster.
|
||||
Find patterns in conditions that produce catastrophic misses.
|
||||
|
||||
Args:
|
||||
all_outcomes: List of resolved outcome dicts. Each must have:
|
||||
'actual_value', 'projected_value', 'player_name', 'grade',
|
||||
'game_context', 'game_date'.
|
||||
percentile: Top percentage of worst misses to track (default 5%).
|
||||
|
||||
Returns:
|
||||
List of catastrophic miss dicts with player, grade, projected,
|
||||
actual, error, conditions, and date.
|
||||
"""
|
||||
if not all_outcomes:
|
||||
return []
|
||||
|
||||
# Calculate absolute error for each outcome
|
||||
scored = []
|
||||
for o in all_outcomes:
|
||||
actual = o.get('actual_value')
|
||||
projected = o.get('projected_value')
|
||||
if actual is not None and projected is not None:
|
||||
scored.append({**o, 'abs_error': abs(actual - projected)})
|
||||
|
||||
if not scored:
|
||||
return []
|
||||
|
||||
scored.sort(key=lambda x: x['abs_error'], reverse=True)
|
||||
cutoff = int(len(scored) * percentile)
|
||||
worst = scored[:max(cutoff, 5)]
|
||||
|
||||
return [{
|
||||
'player': o.get('player_name'),
|
||||
'grade': o.get('grade'),
|
||||
'projected': o.get('projected_value'),
|
||||
'actual': o.get('actual_value'),
|
||||
'error': o.get('abs_error'),
|
||||
'conditions': o.get('game_context', {}),
|
||||
'date': o.get('game_date')
|
||||
} for o in worst]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
VYNDR Capper Content Formatter
|
||||
Pre-formatted post text for manual social posting.
|
||||
Breaking alerts, daily scans, results recap, miss autopsy.
|
||||
A- and above ONLY. SHADOW_MODE first 2 weeks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# Sequential pick counter (loaded from grade_outcomes on boot)
|
||||
_pick_counter = 0
|
||||
|
||||
|
||||
def get_next_pick_number():
|
||||
"""Get and increment sequential pick number."""
|
||||
global _pick_counter
|
||||
_pick_counter += 1
|
||||
return _pick_counter
|
||||
|
||||
|
||||
def set_pick_counter(value):
|
||||
"""Set the pick counter (called on boot from grade_outcomes max)."""
|
||||
global _pick_counter
|
||||
_pick_counter = value
|
||||
|
||||
|
||||
def format_capper_post(grade_result, sport):
|
||||
"""
|
||||
Generate pre-formatted post text for the capper account.
|
||||
Kev copies and posts manually. Automate via X API later.
|
||||
|
||||
Args:
|
||||
grade_result: Grade result dict with player, stat_type, grade, etc.
|
||||
sport: 'nba' or 'mlb'.
|
||||
|
||||
Returns:
|
||||
Formatted post string.
|
||||
"""
|
||||
emoji = '\U0001f3c0' if sport == 'nba' else '\u26be\ufe0f'
|
||||
pick_num = get_next_pick_number()
|
||||
|
||||
if grade_result.get('trigger') == 'beat_reporter_scratch':
|
||||
return (
|
||||
f"BREAKING: {grade_result['scratched_player']} scratched.\n\n"
|
||||
f"{grade_result['player']} {grade_result['stat_type'].upper()} "
|
||||
f"{grade_result['over_under'].upper()} {grade_result['line']} "
|
||||
f"moved from {grade_result['old_grade']} to {grade_result['grade']}.\n\n"
|
||||
f"Engine projection: {grade_result['projected_value']} | "
|
||||
f"Edge: {grade_result.get('real_edge', {}).get('real_edge', 0):.1%}\n\n"
|
||||
f"\U0001f512 {pick_num:03d}"
|
||||
)
|
||||
|
||||
return (
|
||||
f"{emoji} VYNDR Scan\n\n"
|
||||
f"{grade_result.get('player', 'Unknown')} "
|
||||
f"{grade_result.get('over_under', 'over').upper()} "
|
||||
f"{grade_result.get('line', '?')} {grade_result.get('stat_type', '')} "
|
||||
f"\u2192 Grade: {grade_result.get('grade', '?')}\n\n"
|
||||
f"Projection: {grade_result.get('projected_value', '?')} | "
|
||||
f"Line: {grade_result.get('line', '?')} | "
|
||||
f"Edge: {grade_result.get('real_edge', {}).get('real_edge', 0):.1%}\n\n"
|
||||
f"\U0001f512 {pick_num:03d}"
|
||||
)
|
||||
|
||||
|
||||
def format_daily_results(resolved_grades, game_date):
|
||||
"""
|
||||
Format yesterday's results for morning recap post.
|
||||
|
||||
Args:
|
||||
resolved_grades: List of resolved grade dicts.
|
||||
game_date: Date string for the header.
|
||||
|
||||
Returns:
|
||||
Formatted results recap string.
|
||||
"""
|
||||
if not resolved_grades:
|
||||
return f"\U0001f4ca No graded plays for {game_date}."
|
||||
|
||||
lines = [f"\U0001f4ca Yesterday's VYNDR Grades:\n"]
|
||||
|
||||
for g in resolved_grades:
|
||||
icon = '\u2705' if g.get('hit') else '\u274c'
|
||||
pick_num = g.get('pick_number', 0)
|
||||
lines.append(
|
||||
f"{icon} \U0001f512 {pick_num:03d} \u2014 {g.get('player_name', '?')} "
|
||||
f"{g.get('over_under', '').upper()} {g.get('prop_line', '?')} "
|
||||
f"{g.get('stat_type', '')} "
|
||||
f"\u2192 {g.get('grade', '?')} \u2192 "
|
||||
f"{'HIT' if g.get('hit') else 'MISS'} "
|
||||
f"({g.get('actual_value', '?')})"
|
||||
)
|
||||
|
||||
total = len(resolved_grades)
|
||||
hit_count = sum(1 for g in resolved_grades if g.get('hit'))
|
||||
pct = round(hit_count / total * 100) if total > 0 else 0
|
||||
lines.append(
|
||||
f"\nRunning record: {hit_count}-{total - hit_count} "
|
||||
f"({pct}%) on graded plays"
|
||||
)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_miss_autopsy(resolved_grade):
|
||||
"""
|
||||
When an A-grade pick misses, explain WHY.
|
||||
Transparency builds trust more than wins alone.
|
||||
|
||||
Args:
|
||||
resolved_grade: Resolved grade dict with game_context.
|
||||
|
||||
Returns:
|
||||
Formatted miss autopsy string.
|
||||
"""
|
||||
context = resolved_grade.get('game_context', {})
|
||||
reasons = []
|
||||
|
||||
if context.get('player_injured_during_game'):
|
||||
reasons.append(
|
||||
f"Left game with {context.get('injury_type', 'injury')} \u2014 "
|
||||
f"played {context.get('actual_minutes', '?')} of projected "
|
||||
f"{context.get('projected_minutes', '?')} minutes"
|
||||
)
|
||||
if context.get('blowout'):
|
||||
pulled_q = '3rd' if context.get('pulled_quarter') == 3 else '4th'
|
||||
reasons.append(f"Blowout \u2014 pulled in {pulled_q} quarter")
|
||||
if context.get('foul_trouble'):
|
||||
reasons.append(
|
||||
f"Foul trouble \u2014 {context.get('fouls', '?')} fouls, "
|
||||
f"sat extended minutes"
|
||||
)
|
||||
if context.get('ejection'):
|
||||
reasons.append("Ejected from game")
|
||||
if not reasons:
|
||||
reasons.append(
|
||||
"Model miss \u2014 no external factor identified. "
|
||||
"Logged for calibration."
|
||||
)
|
||||
|
||||
pick_num = resolved_grade.get('pick_number', 0)
|
||||
return (
|
||||
f"\U0001f4cb Miss Autopsy \u2014 \U0001f512 {pick_num:03d}\n\n"
|
||||
f"{resolved_grade.get('player_name', '?')} "
|
||||
f"{resolved_grade.get('over_under', '').upper()} "
|
||||
f"{resolved_grade.get('prop_line', '?')} {resolved_grade.get('stat_type', '')}\n"
|
||||
f"Grade: {resolved_grade.get('grade', '?')} | "
|
||||
f"Projected: {resolved_grade.get('projected_value', '?')} | "
|
||||
f"Actual: {resolved_grade.get('actual_value', '?')}\n\n"
|
||||
f"Why: {'. '.join(reasons)}"
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
VYNDR Context Adjustment Aggregator
|
||||
Aggregates all contextual factors into a single context_adjustment value.
|
||||
Used by both NBA and MLB grading pipelines.
|
||||
"""
|
||||
|
||||
# All recognized context factor keys
|
||||
CONTEXT_FACTORS = [
|
||||
'park_factor_adj',
|
||||
'weather_adj',
|
||||
'abs_adj',
|
||||
'home_road_adj',
|
||||
'day_night_adj',
|
||||
'lineup_protection_adj',
|
||||
'opponent_quality_adj',
|
||||
'teammate_impact_adj',
|
||||
'game_script_adj',
|
||||
'bullpen_state_adj',
|
||||
'tto_decay_adj',
|
||||
'catcher_framing_adj',
|
||||
'travel_fatigue_adj',
|
||||
'umpire_adj',
|
||||
'referee_adj',
|
||||
]
|
||||
|
||||
|
||||
def aggregate_context_adjustments(factors):
|
||||
"""
|
||||
Aggregate all contextual factors into a single context_adjustment value.
|
||||
Each factor is a float adjustment to the player's projected stat.
|
||||
|
||||
Args:
|
||||
factors: Dict mapping factor names to float adjustments.
|
||||
Missing factors default to 0.0.
|
||||
|
||||
Returns:
|
||||
Float — total context adjustment (sum of all factors).
|
||||
"""
|
||||
if not factors:
|
||||
return 0.0
|
||||
return sum(factors.get(k, 0.0) for k in CONTEXT_FACTORS)
|
||||
|
||||
|
||||
def decompose_context(factors):
|
||||
"""
|
||||
Return a breakdown of all non-zero context adjustments for grade response.
|
||||
|
||||
Args:
|
||||
factors: Dict mapping factor names to float adjustments.
|
||||
|
||||
Returns:
|
||||
Dict of non-zero factors with their values.
|
||||
"""
|
||||
if not factors:
|
||||
return {}
|
||||
return {k: round(factors[k], 3) for k in CONTEXT_FACTORS
|
||||
if factors.get(k, 0.0) != 0.0}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
VYNDR Data Warehouse
|
||||
Local-first data layer with game-day TTL override.
|
||||
Every external API response stored locally. Check cache first, API only if stale.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time as _time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from utils.retry import api_call_with_retry
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
# In-memory cache (process-local). Supabase backing store for persistence across restarts.
|
||||
_local_cache = {}
|
||||
|
||||
DATA_FRESHNESS = {
|
||||
'odds': {'default_ttl': 0.25, 'game_day_ttl': 0.083}, # 15min / 5min
|
||||
'lineups': {'default_ttl': 1.0, 'game_day_ttl': 0.25}, # 1hr / 15min
|
||||
'player_stats': {'default_ttl': 24, 'game_day_ttl': 6}, # 24hr / 6hr
|
||||
'weather': {'default_ttl': 6, 'game_day_ttl': 0.5}, # 6hr / 30min (continuous)
|
||||
'park_factors': {'default_ttl': 720, 'game_day_ttl': 720}, # 30 days
|
||||
'reporter_feed': {'default_ttl': 0.017, 'game_day_ttl': 0.017} # ~1min
|
||||
}
|
||||
|
||||
|
||||
def get_from_local_cache(cache_key):
|
||||
"""
|
||||
Retrieve data from in-memory cache.
|
||||
|
||||
Args:
|
||||
cache_key: Unique cache key string.
|
||||
|
||||
Returns:
|
||||
Dict with 'data' and 'fetched_at' keys, or None if not cached.
|
||||
"""
|
||||
return _local_cache.get(cache_key)
|
||||
|
||||
|
||||
def store_in_local_cache(cache_key, data):
|
||||
"""
|
||||
Store data in in-memory cache with timestamp.
|
||||
|
||||
Args:
|
||||
cache_key: Unique cache key string.
|
||||
data: Any serializable data to cache.
|
||||
"""
|
||||
_local_cache[cache_key] = {
|
||||
'data': data,
|
||||
'fetched_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def is_fresh(fetched_at_str, ttl_hours):
|
||||
"""
|
||||
Check if cached data is still within its TTL.
|
||||
|
||||
Args:
|
||||
fetched_at_str: ISO format timestamp of when data was fetched.
|
||||
ttl_hours: Time-to-live in hours.
|
||||
|
||||
Returns:
|
||||
True if data is still fresh, False if stale.
|
||||
"""
|
||||
try:
|
||||
fetched_at = datetime.fromisoformat(fetched_at_str)
|
||||
age_hours = (datetime.utcnow() - fetched_at).total_seconds() / 3600
|
||||
return age_hours < ttl_hours
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def clear_cache(cache_key=None):
|
||||
"""
|
||||
Clear local cache. If cache_key provided, clear only that key.
|
||||
Otherwise clear entire cache.
|
||||
"""
|
||||
if cache_key:
|
||||
_local_cache.pop(cache_key, None)
|
||||
else:
|
||||
_local_cache.clear()
|
||||
|
||||
|
||||
def fetch_with_cache(cache_key, fetch_func, data_type='player_stats',
|
||||
has_game_today=False, *args, **kwargs):
|
||||
"""
|
||||
Fetch data with cache-first strategy and game-day TTL override.
|
||||
|
||||
Args:
|
||||
cache_key: Unique identifier for this data.
|
||||
fetch_func: Callable that fetches fresh data from external source.
|
||||
data_type: Key into DATA_FRESHNESS for TTL configuration.
|
||||
has_game_today: If True, use shorter game-day TTL.
|
||||
*args, **kwargs: Passed to fetch_func.
|
||||
|
||||
Returns:
|
||||
Fetched data dict, or None if both cache and API fail.
|
||||
Stale data includes '_stale': True flag.
|
||||
"""
|
||||
freshness = DATA_FRESHNESS.get(data_type, {'default_ttl': 6, 'game_day_ttl': 6})
|
||||
ttl = freshness['game_day_ttl'] if has_game_today else freshness['default_ttl']
|
||||
|
||||
# Check local cache first
|
||||
local = get_from_local_cache(cache_key)
|
||||
if local and is_fresh(local['fetched_at'], ttl):
|
||||
return local['data']
|
||||
|
||||
# Fetch fresh data through retry wrapper
|
||||
fresh_data = api_call_with_retry(fetch_func, *args, **kwargs)
|
||||
if fresh_data is not None:
|
||||
store_in_local_cache(cache_key, fresh_data)
|
||||
return fresh_data
|
||||
|
||||
# Fallback to stale cache if API failed
|
||||
if local:
|
||||
logger.warning(f'[VYNDR] Using stale cache for {cache_key}')
|
||||
stale_data = local['data']
|
||||
if isinstance(stale_data, dict):
|
||||
return {**stale_data, '_stale': True}
|
||||
return stale_data
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
VYNDR Edge Calculator
|
||||
Real edge with vig adjustment + quarter-Kelly criterion.
|
||||
"""
|
||||
|
||||
|
||||
def calculate_real_edge(model_probability, american_odds):
|
||||
"""
|
||||
Calculate edge AFTER accounting for the vig.
|
||||
This is the bettor's actual expected value — not the raw probability gap.
|
||||
|
||||
Args:
|
||||
model_probability: Model's estimated probability of the bet hitting (0.0-1.0).
|
||||
american_odds: American odds format (e.g., -110, +150).
|
||||
|
||||
Returns:
|
||||
Dict with model_probability, implied_probability, real_edge,
|
||||
ev_per_dollar, is_positive_ev, min_probability_to_bet.
|
||||
"""
|
||||
if american_odds < 0:
|
||||
implied_prob = abs(american_odds) / (abs(american_odds) + 100)
|
||||
payout_multiplier = 100 / abs(american_odds)
|
||||
else:
|
||||
implied_prob = 100 / (american_odds + 100)
|
||||
payout_multiplier = american_odds / 100
|
||||
|
||||
real_edge = model_probability - implied_prob
|
||||
ev_per_dollar = (model_probability * payout_multiplier) - ((1 - model_probability) * 1.0)
|
||||
|
||||
return {
|
||||
'model_probability': round(model_probability, 3),
|
||||
'implied_probability': round(implied_prob, 3),
|
||||
'real_edge': round(real_edge, 3),
|
||||
'ev_per_dollar': round(ev_per_dollar, 3),
|
||||
'is_positive_ev': ev_per_dollar > 0,
|
||||
'min_probability_to_bet': round(implied_prob, 3)
|
||||
}
|
||||
|
||||
|
||||
def kelly_criterion(model_probability, american_odds, fraction=0.25):
|
||||
"""
|
||||
Kelly-optimal bet size. Uses fractional Kelly (quarter) to reduce variance.
|
||||
Full Kelly is too aggressive for most bettors.
|
||||
|
||||
Args:
|
||||
model_probability: Model's estimated probability of winning (0.0-1.0).
|
||||
american_odds: American odds format.
|
||||
fraction: Kelly fraction to use (default 0.25 = quarter Kelly).
|
||||
|
||||
Returns:
|
||||
Dict with full_kelly_pct, recommended_pct, fraction_used, recommendation.
|
||||
"""
|
||||
if american_odds < 0:
|
||||
decimal_odds = 1 + (100 / abs(american_odds))
|
||||
else:
|
||||
decimal_odds = 1 + (american_odds / 100)
|
||||
|
||||
b = decimal_odds - 1
|
||||
p = model_probability
|
||||
q = 1 - p
|
||||
|
||||
if b <= 0:
|
||||
return {'recommended_pct': 0, 'recommendation': 'NO BET — invalid odds'}
|
||||
|
||||
kelly_pct = ((b * p) - q) / b
|
||||
if kelly_pct <= 0:
|
||||
return {'recommended_pct': 0, 'recommendation': 'NO BET — negative expected value'}
|
||||
|
||||
recommended = round(kelly_pct * fraction * 100, 1)
|
||||
return {
|
||||
'full_kelly_pct': round(kelly_pct * 100, 1),
|
||||
'recommended_pct': recommended,
|
||||
'fraction_used': fraction,
|
||||
'recommendation': f'{recommended}% of bankroll'
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
VYNDR Environment Variable Checker
|
||||
Runs at startup. Exits if required vars missing. Warns on recommended.
|
||||
Never logs secret values.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
REQUIRED_VARS = {
|
||||
'SUPABASE_URL': 'Supabase project URL',
|
||||
'SUPABASE_SERVICE_ROLE_KEY': 'Supabase service role key',
|
||||
'SUPABASE_JWT_SECRET': 'Supabase JWT signing secret',
|
||||
}
|
||||
|
||||
RECOMMENDED_VARS = {
|
||||
'ODDS_API_KEY': 'The Odds API key (required for odds scanning)',
|
||||
'REDIS_URL': 'Upstash Redis URL (required for caching)',
|
||||
'VYNDR_INTERNAL_KEY': 'Internal API key for cron jobs (legacy: BETONBLK_INTERNAL_KEY)',
|
||||
'ALLOWED_ORIGINS': 'CORS allowed origins (defaults to localhost)',
|
||||
'SHADOW_MODE': 'Shadow mode flag (defaults to true)',
|
||||
'ALT_LINE_MODE': 'Alt line mode (defaults to manual)',
|
||||
}
|
||||
|
||||
# Env vars whose values must never be logged. Both internal-key names listed
|
||||
# so the legacy var stays redacted during the rename window.
|
||||
NEVER_LOG = [
|
||||
'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_JWT_SECRET', 'ODDS_API_KEY',
|
||||
'REDIS_URL', 'VYNDR_INTERNAL_KEY', 'BETONBLK_INTERNAL_KEY', 'STRIPE_SECRET_KEY'
|
||||
]
|
||||
|
||||
# Vars where presence under EITHER name satisfies the recommended check.
|
||||
# Tuple: (canonical, [legacy aliases]).
|
||||
_ALIASED_VARS = [('VYNDR_INTERNAL_KEY', ['BETONBLK_INTERNAL_KEY'])]
|
||||
|
||||
|
||||
def _has_any(name, aliases):
|
||||
if os.environ.get(name):
|
||||
return True
|
||||
return any(os.environ.get(a) for a in aliases)
|
||||
|
||||
|
||||
def check_environment(exit_on_missing=True):
|
||||
"""
|
||||
Verify all required environment variables are present.
|
||||
Exit if critical vars missing (unless exit_on_missing=False for testing).
|
||||
|
||||
Args:
|
||||
exit_on_missing: If True, sys.exit(1) when required vars missing.
|
||||
|
||||
Returns:
|
||||
Dict with 'missing_required' and 'missing_recommended' lists.
|
||||
"""
|
||||
missing_required = []
|
||||
missing_recommended = []
|
||||
|
||||
for var, description in REQUIRED_VARS.items():
|
||||
if not os.environ.get(var):
|
||||
missing_required.append(f'{var} — {description}')
|
||||
|
||||
alias_lookup = {canonical: aliases for canonical, aliases in _ALIASED_VARS}
|
||||
for var, description in RECOMMENDED_VARS.items():
|
||||
aliases = alias_lookup.get(var, [])
|
||||
if not _has_any(var, aliases):
|
||||
missing_recommended.append(f'{var} — {description}')
|
||||
|
||||
if missing_required:
|
||||
logger.critical('[SECURITY] Missing REQUIRED environment variables:')
|
||||
for m in missing_required:
|
||||
logger.critical(f' - {m}')
|
||||
if exit_on_missing:
|
||||
logger.critical('[SECURITY] Cannot start without required variables. Exiting.')
|
||||
sys.exit(1)
|
||||
|
||||
if missing_recommended:
|
||||
logger.warning('[SECURITY] Missing recommended environment variables:')
|
||||
for m in missing_recommended:
|
||||
logger.warning(f' - {m}')
|
||||
|
||||
logger.info('[SECURITY] Environment check passed')
|
||||
return {
|
||||
'missing_required': missing_required,
|
||||
'missing_recommended': missing_recommended
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
VYNDR Regime Detector
|
||||
Detects material shifts in team-level metrics via PELT.
|
||||
When detected: reset the 'recent' window for all players on the team.
|
||||
Disabled when team has <20 games played.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
MIN_GAMES_FOR_DETECTION = 20
|
||||
MONITORED_METRICS = ['pace', 'off_rating', 'three_rate', 'usage_entropy']
|
||||
|
||||
|
||||
def detect_team_regime_change(team_games, lookback_games=20):
|
||||
"""
|
||||
Detect material shifts in team-level metrics that indicate
|
||||
a regime change (coaching change, major trade, philosophy shift).
|
||||
|
||||
Args:
|
||||
team_games: List of team game dicts with metric values.
|
||||
Each dict must have keys for at least some MONITORED_METRICS.
|
||||
lookback_games: Number of recent games to analyze.
|
||||
|
||||
Returns:
|
||||
Dict with regime_change_detected (bool), and if detected:
|
||||
change_game_index, change_date, affected_metric, recommendation.
|
||||
"""
|
||||
if not team_games or len(team_games) < MIN_GAMES_FOR_DETECTION:
|
||||
return {
|
||||
'regime_change_detected': False,
|
||||
'reason': 'insufficient_data',
|
||||
'games_available': len(team_games) if team_games else 0,
|
||||
'minimum_required': MIN_GAMES_FOR_DETECTION
|
||||
}
|
||||
|
||||
games = team_games[-lookback_games:]
|
||||
|
||||
for metric in MONITORED_METRICS:
|
||||
values = [g.get(metric) for g in games if g.get(metric) is not None]
|
||||
if len(values) < MIN_GAMES_FOR_DETECTION:
|
||||
continue
|
||||
|
||||
changepoints = _detect_changepoints_simple(values)
|
||||
if changepoints:
|
||||
latest_cp = max(changepoints)
|
||||
# Only flag if the change is recent (last 5 games of the window)
|
||||
if latest_cp >= len(values) - 5:
|
||||
game_index = len(team_games) - len(games) + latest_cp
|
||||
return {
|
||||
'regime_change_detected': True,
|
||||
'change_game_index': latest_cp,
|
||||
'change_date': games[latest_cp].get('game_date'),
|
||||
'affected_metric': metric,
|
||||
'recommendation': 'reset_recent_window_to_change_date'
|
||||
}
|
||||
|
||||
return {'regime_change_detected': False}
|
||||
|
||||
|
||||
def _detect_changepoints_simple(values, threshold=2.0):
|
||||
"""
|
||||
Simple CUSUM-based changepoint detection.
|
||||
Used when full PELT is overkill for team-level detection.
|
||||
|
||||
Args:
|
||||
values: List of numeric values.
|
||||
threshold: Z-score threshold for detecting a changepoint.
|
||||
|
||||
Returns:
|
||||
List of changepoint indices.
|
||||
"""
|
||||
if len(values) < 10:
|
||||
return []
|
||||
|
||||
signal = np.array(values, dtype=float)
|
||||
overall_mean = np.mean(signal)
|
||||
overall_std = max(np.std(signal), 0.01)
|
||||
|
||||
window = max(5, len(signal) // 4)
|
||||
changepoints = []
|
||||
|
||||
for i in range(window, len(signal) - window + 1):
|
||||
left_mean = np.mean(signal[i - window:i])
|
||||
right_mean = np.mean(signal[i:i + window])
|
||||
diff = abs(right_mean - left_mean) / overall_std
|
||||
if diff > threshold:
|
||||
# Deduplicate: skip if too close to last detected
|
||||
if not changepoints or i - changepoints[-1] >= window:
|
||||
changepoints.append(i)
|
||||
|
||||
return changepoints
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
VYNDR Retry Logic
|
||||
ALL external API calls use this wrapper. 3 attempts, exponential backoff.
|
||||
Never returns an unhandled error to the user.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
|
||||
def api_call_with_retry(func, *args, max_retries=3, base_delay=1.0, **kwargs):
|
||||
"""
|
||||
Execute a function with retry logic and exponential backoff.
|
||||
|
||||
Args:
|
||||
func: Callable to execute.
|
||||
*args: Positional arguments passed to func.
|
||||
max_retries: Maximum number of attempts (default 3).
|
||||
base_delay: Base delay in seconds between retries (default 1.0).
|
||||
**kwargs: Keyword arguments passed to func.
|
||||
|
||||
Returns:
|
||||
The return value of func, or None if all retries fail.
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
delay = base_delay * (2 ** attempt)
|
||||
logger.warning(
|
||||
f'[VYNDR] API attempt {attempt + 1} failed: {e}. '
|
||||
f'Retrying in {delay}s'
|
||||
)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
f'[VYNDR] API failed after {max_retries} attempts: {e}'
|
||||
)
|
||||
log_api_failure(func.__name__ if hasattr(func, '__name__') else str(func), str(e))
|
||||
return None
|
||||
|
||||
|
||||
def log_api_failure(api_name, error_message):
|
||||
"""
|
||||
Log API failure to Supabase api_health_log table.
|
||||
Non-fatal — if Supabase itself is down, just log to stderr.
|
||||
"""
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if supabase:
|
||||
from datetime import datetime
|
||||
supabase.table('api_health_log').insert({
|
||||
'api_name': api_name,
|
||||
'error_message': error_message,
|
||||
'failed_at': datetime.utcnow().isoformat(),
|
||||
'games_tonight': 0
|
||||
}).execute()
|
||||
except Exception as e:
|
||||
logger.error(f'[VYNDR] Failed to log API failure: {e}')
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
VYNDR Security Logger
|
||||
Logs suspicious requests. Detects SQL injection patterns.
|
||||
Tracks request rates per IP. Stores events in security_events table.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger('vyndr.security')
|
||||
|
||||
_request_counts = defaultdict(list)
|
||||
ALERT_THRESHOLD = 100 # requests per minute from same IP
|
||||
|
||||
|
||||
def get_real_ip(req):
|
||||
"""Extract real client IP from X-Forwarded-For or remote_addr."""
|
||||
forwarded = req.headers.get('X-Forwarded-For', '')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return req.remote_addr or '127.0.0.1'
|
||||
|
||||
|
||||
def log_request(req):
|
||||
"""
|
||||
Log every API request with security-relevant info.
|
||||
Detects rate abuse and SQL injection patterns.
|
||||
Must not block request processing.
|
||||
|
||||
Args:
|
||||
req: Flask request object.
|
||||
"""
|
||||
try:
|
||||
ip = get_real_ip(req)
|
||||
path = req.path
|
||||
method = req.method
|
||||
|
||||
# Track request rate per IP
|
||||
now = datetime.utcnow()
|
||||
_request_counts[ip] = [
|
||||
t for t in _request_counts[ip]
|
||||
if t > now - timedelta(minutes=1)
|
||||
]
|
||||
_request_counts[ip].append(now)
|
||||
|
||||
# Alert on rate abuse
|
||||
if len(_request_counts[ip]) > ALERT_THRESHOLD:
|
||||
logger.critical(
|
||||
f'[SECURITY] Rate abuse from {ip}: '
|
||||
f'{len(_request_counts[ip])} req/min on {path}'
|
||||
)
|
||||
log_security_event('rate_abuse', ip, path, len(_request_counts[ip]))
|
||||
|
||||
# Check request body for SQL injection
|
||||
if req.data and method in ('POST', 'PUT', 'PATCH'):
|
||||
_check_injection(req, ip, path)
|
||||
|
||||
except Exception as e:
|
||||
# Security logging must NEVER block request processing
|
||||
logger.error(f'[SECURITY] Logger error: {e}')
|
||||
|
||||
|
||||
def _check_injection(req, ip, path):
|
||||
"""Check request body for SQL injection patterns."""
|
||||
try:
|
||||
body = req.get_json(silent=True)
|
||||
if body:
|
||||
body_str = str(body).lower()
|
||||
injection_patterns = [
|
||||
'drop table', 'delete from', 'insert into',
|
||||
'union select', '--', ';--', 'or 1=1'
|
||||
]
|
||||
for pattern in injection_patterns:
|
||||
if pattern in body_str:
|
||||
logger.critical(
|
||||
f'[SECURITY] SQL injection attempt from {ip}: '
|
||||
f'{pattern} in {path}'
|
||||
)
|
||||
log_security_event('sql_injection', ip, path, body_str[:200])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def log_security_event(event_type, ip, path, detail):
|
||||
"""
|
||||
Store security event in database for review.
|
||||
|
||||
Args:
|
||||
event_type: Category string (rate_abuse, sql_injection, etc.).
|
||||
ip: Client IP address.
|
||||
path: Request path.
|
||||
detail: Additional detail string (truncated to 500 chars).
|
||||
"""
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if supabase:
|
||||
supabase.table('security_events').insert({
|
||||
'event_type': event_type,
|
||||
'ip_address': ip,
|
||||
'path': path,
|
||||
'detail': str(detail)[:500],
|
||||
'created_at': datetime.utcnow().isoformat()
|
||||
}).execute()
|
||||
except Exception as e:
|
||||
logger.error(f'[SECURITY] Failed to log event: {e}')
|
||||
|
||||
|
||||
def cleanup_old_security_events(retention_days=90):
|
||||
"""
|
||||
Auto-delete security logs older than retention period.
|
||||
Called by nightly resolution job.
|
||||
|
||||
Args:
|
||||
retention_days: Number of days to retain (default 90).
|
||||
"""
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if supabase:
|
||||
cutoff = (datetime.utcnow() - timedelta(days=retention_days)).isoformat()
|
||||
supabase.table('security_events').delete().lt('created_at', cutoff).execute()
|
||||
logger.info(f'[Security] Cleaned up events older than {retention_days} days')
|
||||
except Exception as e:
|
||||
logger.error(f'[Security] Cleanup failed: {e}')
|
||||
|
||||
|
||||
def generate_security_digest():
|
||||
"""
|
||||
Weekly summary of security events. Flags IPs with 50+ events.
|
||||
|
||||
Returns:
|
||||
Dict with period, total_events, by_type, top_ips, action_required.
|
||||
"""
|
||||
try:
|
||||
from utils.supabase_client import get_supabase_client
|
||||
supabase = get_supabase_client()
|
||||
if not supabase:
|
||||
return {'error': 'Supabase not available'}
|
||||
|
||||
week_ago = (datetime.utcnow() - timedelta(days=7)).isoformat()
|
||||
result = supabase.table('security_events').select('*').gte(
|
||||
'created_at', week_ago
|
||||
).execute()
|
||||
events = result.data if result else []
|
||||
|
||||
summary = {
|
||||
'period': f'{week_ago} to now',
|
||||
'total_events': len(events),
|
||||
'by_type': {},
|
||||
'top_ips': {},
|
||||
'action_required': []
|
||||
}
|
||||
|
||||
for event in events:
|
||||
t = event.get('event_type', 'unknown')
|
||||
summary['by_type'][t] = summary['by_type'].get(t, 0) + 1
|
||||
ip = event.get('ip_address', 'unknown')
|
||||
summary['top_ips'][ip] = summary['top_ips'].get(ip, 0) + 1
|
||||
|
||||
for ip, count in summary['top_ips'].items():
|
||||
if count >= 50:
|
||||
summary['action_required'].append(f'Block IP {ip}: {count} events')
|
||||
|
||||
return summary
|
||||
except Exception as e:
|
||||
logger.error(f'[Security] Digest failed: {e}')
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
VYNDR Similarity Engine
|
||||
Find historically similar games for confidence adjustment.
|
||||
Shared by NBA and MLB. Minimum similarity threshold 0.7.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
MIN_SIMILARITY = 0.7
|
||||
|
||||
# Similarity factors and their relative importance
|
||||
SIMILARITY_FACTORS = {
|
||||
# NBA factors
|
||||
'opponent_defensive_rating': 0.15,
|
||||
'pace': 0.12,
|
||||
'rest_days': 0.08,
|
||||
'home_away': 0.06,
|
||||
'functional_role_match': 0.15,
|
||||
'teammate_context': 0.10,
|
||||
# MLB factors
|
||||
'pitcher_handedness': 0.12,
|
||||
'park_factor': 0.10,
|
||||
'opponent_quality': 0.12,
|
||||
'weather_similarity': 0.05,
|
||||
'day_night': 0.04,
|
||||
'batting_order_position': 0.06,
|
||||
}
|
||||
|
||||
|
||||
def calculate_similarity_score(game_a, game_b, factors=None):
|
||||
"""
|
||||
Calculate similarity score between two games.
|
||||
Uses weighted factor comparison with normalization.
|
||||
|
||||
Args:
|
||||
game_a: Dict of game context factors.
|
||||
game_b: Dict of game context factors.
|
||||
factors: Optional dict of factor weights. Defaults to SIMILARITY_FACTORS.
|
||||
|
||||
Returns:
|
||||
Float similarity score between 0.0 and 1.0.
|
||||
"""
|
||||
if factors is None:
|
||||
factors = SIMILARITY_FACTORS
|
||||
|
||||
total_score = 0.0
|
||||
total_weight = 0.0
|
||||
|
||||
for factor, weight in factors.items():
|
||||
val_a = game_a.get(factor)
|
||||
val_b = game_b.get(factor)
|
||||
if val_a is None or val_b is None:
|
||||
continue
|
||||
|
||||
# Boolean factors
|
||||
if isinstance(val_a, bool) or isinstance(val_b, bool):
|
||||
similarity = 1.0 if val_a == val_b else 0.0
|
||||
# String factors (categorical)
|
||||
elif isinstance(val_a, str) or isinstance(val_b, str):
|
||||
similarity = 1.0 if val_a == val_b else 0.0
|
||||
# Numeric factors
|
||||
else:
|
||||
max_val = max(abs(val_a), abs(val_b), 1)
|
||||
diff = abs(val_a - val_b) / max_val
|
||||
similarity = max(0.0, 1.0 - diff)
|
||||
|
||||
total_score += similarity * weight
|
||||
total_weight += weight
|
||||
|
||||
if total_weight == 0:
|
||||
return 0.0
|
||||
return min(1.0, max(0.0, total_score / total_weight))
|
||||
|
||||
|
||||
def find_similar_games(target_game, historical_games, max_results=5, min_similarity=None):
|
||||
"""
|
||||
Find historically similar games above the minimum similarity threshold.
|
||||
|
||||
Args:
|
||||
target_game: Dict of current game context factors.
|
||||
historical_games: List of historical game dicts.
|
||||
max_results: Maximum number of similar games to return.
|
||||
min_similarity: Minimum similarity score threshold (default 0.7).
|
||||
|
||||
Returns:
|
||||
List of (similarity_score, game) tuples, sorted by similarity descending.
|
||||
Only games at or above min_similarity are included.
|
||||
"""
|
||||
if min_similarity is None:
|
||||
min_similarity = MIN_SIMILARITY
|
||||
|
||||
scored = []
|
||||
for game in historical_games:
|
||||
score = calculate_similarity_score(target_game, game)
|
||||
if score >= min_similarity:
|
||||
scored.append((score, game))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:max_results]
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
VYNDR Sportsbook Deep Links + Parlay Builder
|
||||
10 books. Deep link to game/player page. Parlay grading with correlation check.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
SPORTSBOOKS = {
|
||||
'draftkings': {
|
||||
'name': 'DraftKings',
|
||||
'base_url': 'https://sportsbook.draftkings.com',
|
||||
'deep_link_pattern': '/event/{event_id}'
|
||||
},
|
||||
'fanduel': {
|
||||
'name': 'FanDuel',
|
||||
'base_url': 'https://sportsbook.fanduel.com',
|
||||
'deep_link_pattern': '/sport/{sport}/event/{event_id}'
|
||||
},
|
||||
'betmgm': {
|
||||
'name': 'BetMGM',
|
||||
'base_url': 'https://sports.betmgm.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'caesars': {
|
||||
'name': 'Caesars',
|
||||
'base_url': 'https://www.caesars.com/sportsbook-and-casino',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'bet365': {
|
||||
'name': 'bet365',
|
||||
'base_url': 'https://www.bet365.com',
|
||||
'deep_link_pattern': '/#/AC/B{sport_id}/C{event_id}'
|
||||
},
|
||||
'pointsbet': {
|
||||
'name': 'PointsBet',
|
||||
'base_url': 'https://pointsbet.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'betrivers': {
|
||||
'name': 'BetRivers',
|
||||
'base_url': 'https://www.betrivers.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'fanatics': {
|
||||
'name': 'Fanatics',
|
||||
'base_url': 'https://sportsbook.fanatics.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'hardrockbet': {
|
||||
'name': 'Hard Rock Bet',
|
||||
'base_url': 'https://app.hardrockbet.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
},
|
||||
'espnbet': {
|
||||
'name': 'ESPN BET',
|
||||
'base_url': 'https://espnbet.com',
|
||||
'deep_link_pattern': '/sports/{sport}/event/{event_id}'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def grade_parlay(legs, grade_fn):
|
||||
"""
|
||||
Grade a parlay: grade each leg, compound probability, apply penalty.
|
||||
Parlay grade = average leg confidence minus penalty per leg after 2.
|
||||
|
||||
Args:
|
||||
legs: List of leg dicts with grading params.
|
||||
grade_fn: Function to grade a single leg.
|
||||
|
||||
Returns:
|
||||
Dict with parlay_grade, compound_probability, individual grades, warnings.
|
||||
"""
|
||||
graded_legs = []
|
||||
compound_prob = 1.0
|
||||
|
||||
for leg in legs:
|
||||
result = grade_fn(leg)
|
||||
graded_legs.append(result)
|
||||
compound_prob *= result.get('confidence', 0.5)
|
||||
|
||||
if not graded_legs:
|
||||
return {'error': 'No legs to grade'}
|
||||
|
||||
# Average confidence
|
||||
avg_confidence = sum(l.get('confidence', 0.5) for l in graded_legs) / len(graded_legs)
|
||||
|
||||
# Penalty per leg after 2 (each extra leg subtracts 0.03)
|
||||
leg_penalty = max(0, len(graded_legs) - 2) * 0.03
|
||||
parlay_confidence = max(0.0, avg_confidence - leg_penalty)
|
||||
|
||||
# Warning on 4+ legs
|
||||
warnings = []
|
||||
if len(graded_legs) >= 4:
|
||||
warnings.append({
|
||||
'type': 'leg_count',
|
||||
'message': f'{len(graded_legs)} legs — compound probability is {compound_prob:.4f}. '
|
||||
'Sportsbooks profit most from large parlays.'
|
||||
})
|
||||
|
||||
# Correlation check
|
||||
correlation_warnings = check_parlay_correlation(graded_legs)
|
||||
warnings.extend(correlation_warnings)
|
||||
|
||||
return {
|
||||
'parlay_confidence': round(parlay_confidence, 3),
|
||||
'compound_probability': round(compound_prob, 6),
|
||||
'leg_count': len(graded_legs),
|
||||
'leg_penalty': round(leg_penalty, 3),
|
||||
'legs': graded_legs,
|
||||
'warnings': warnings
|
||||
}
|
||||
|
||||
|
||||
def check_parlay_correlation(legs):
|
||||
"""
|
||||
Check for correlated legs in a parlay.
|
||||
Same-game detection is free. Structural correlation applies immediately.
|
||||
Statistical correlation (phi) needs 30+ joint outcomes.
|
||||
|
||||
Args:
|
||||
legs: List of graded leg dicts with game_id, team, player_id, stat_type.
|
||||
|
||||
Returns:
|
||||
List of correlation warning dicts.
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
# Group by game
|
||||
game_groups = {}
|
||||
for i, leg in enumerate(legs):
|
||||
gid = leg.get('game_id', f'unknown_{i}')
|
||||
game_groups.setdefault(gid, []).append(leg)
|
||||
|
||||
for game_id, game_legs in game_groups.items():
|
||||
if len(game_legs) < 2:
|
||||
continue
|
||||
|
||||
warnings.append({
|
||||
'type': 'same_game',
|
||||
'game_id': game_id,
|
||||
'legs_affected': len(game_legs),
|
||||
'message': f'{len(game_legs)} legs from the same game — correlation risk'
|
||||
})
|
||||
|
||||
# Structural correlation: same team
|
||||
team_groups = {}
|
||||
for leg in game_legs:
|
||||
team = leg.get('team', 'unknown')
|
||||
team_groups.setdefault(team, []).append(leg)
|
||||
|
||||
for team, team_legs in team_groups.items():
|
||||
if len(team_legs) >= 2:
|
||||
penalty = 0.03 * (len(team_legs) - 1)
|
||||
warnings.append({
|
||||
'type': 'structural_correlation',
|
||||
'team': team,
|
||||
'penalty': penalty,
|
||||
'message': f'{len(team_legs)} props on same team — '
|
||||
f'{penalty * 100:.0f}% confidence reduction'
|
||||
})
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def get_phi_coefficient(player_a_id, player_b_id, stat_a, stat_b, joint_outcomes=None):
|
||||
"""
|
||||
Calculate phi coefficient from joint outcomes.
|
||||
Requires minimum 30 joint instances before reporting.
|
||||
|
||||
Args:
|
||||
player_a_id: First player ID.
|
||||
player_b_id: Second player ID.
|
||||
stat_a: First stat type.
|
||||
stat_b: Second stat type.
|
||||
joint_outcomes: Optional list of joint outcome dicts.
|
||||
|
||||
Returns:
|
||||
Float phi coefficient, or None if insufficient data.
|
||||
"""
|
||||
if not joint_outcomes or len(joint_outcomes) < 30:
|
||||
return None
|
||||
|
||||
# 2x2 contingency table
|
||||
a = sum(1 for j in joint_outcomes if j['hit_a'] and j['hit_b'])
|
||||
b = sum(1 for j in joint_outcomes if j['hit_a'] and not j['hit_b'])
|
||||
c = sum(1 for j in joint_outcomes if not j['hit_a'] and j['hit_b'])
|
||||
d = sum(1 for j in joint_outcomes if not j['hit_a'] and not j['hit_b'])
|
||||
|
||||
n = a + b + c + d
|
||||
if n == 0:
|
||||
return None
|
||||
|
||||
denom = ((a + b) * (c + d) * (a + c) * (b + d)) ** 0.5
|
||||
if denom == 0:
|
||||
return None
|
||||
|
||||
phi = (a * d - b * c) / denom
|
||||
return round(phi, 3)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
VYNDR Supabase Client
|
||||
Singleton Supabase client for Python service.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_supabase_client():
|
||||
"""
|
||||
Get or create Supabase client singleton.
|
||||
|
||||
Returns:
|
||||
Supabase client instance, or None if credentials not configured.
|
||||
"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
|
||||
url = os.environ.get('SUPABASE_URL')
|
||||
key = os.environ.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
if not url or not key:
|
||||
logger.warning('[VYNDR] Supabase credentials not configured')
|
||||
return None
|
||||
|
||||
try:
|
||||
from supabase import create_client
|
||||
_client = create_client(url, key)
|
||||
return _client
|
||||
except ImportError:
|
||||
logger.warning('[VYNDR] supabase-py not installed')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f'[VYNDR] Supabase client init failed: {e}')
|
||||
return None
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
VYNDR Input Validation
|
||||
Sanitize and validate all user inputs before processing.
|
||||
Prevents injection, overflow, and malformed data.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
MAX_PLAYER_NAME = 100
|
||||
MAX_STAT_TYPE = 50
|
||||
MAX_SPORT = 10
|
||||
|
||||
VALID_STAT_TYPES = {
|
||||
'nba': ['points', 'rebounds', 'assists', 'threes', 'pts_reb_ast',
|
||||
'steals', 'blocks', 'turnovers'],
|
||||
'mlb': ['strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
|
||||
'walks', 'runs', 'earned_runs', 'innings_pitched',
|
||||
'hits_allowed', 'stolen_bases']
|
||||
}
|
||||
|
||||
VALID_SPORTS = ['nba', 'mlb']
|
||||
VALID_OVER_UNDER = ['over', 'under']
|
||||
|
||||
SQL_INJECTION_PATTERNS = [
|
||||
'drop table', 'delete from', 'insert into',
|
||||
'union select', '--', ';--', 'or 1=1', "' or '",
|
||||
'exec(', 'execute(', 'xp_cmdshell'
|
||||
]
|
||||
|
||||
|
||||
def sanitize_string(value, max_length=100):
|
||||
"""
|
||||
Remove dangerous characters and enforce length limit.
|
||||
|
||||
Args:
|
||||
value: Input string.
|
||||
max_length: Maximum allowed length.
|
||||
|
||||
Returns:
|
||||
Sanitized string, or None if input is invalid.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip()
|
||||
# Remove SQL injection characters
|
||||
value = re.sub(r'[;\'"\\`]', '', value)
|
||||
# Remove HTML/script tags
|
||||
value = re.sub(r'<[^>]+>', '', value)
|
||||
return value[:max_length] if value else None
|
||||
|
||||
|
||||
def check_sql_injection(value):
|
||||
"""
|
||||
Check if a string contains SQL injection patterns.
|
||||
|
||||
Args:
|
||||
value: Input string to check.
|
||||
|
||||
Returns:
|
||||
True if injection pattern detected, False otherwise.
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
lower = str(value).lower()
|
||||
return any(pattern in lower for pattern in SQL_INJECTION_PATTERNS)
|
||||
|
||||
|
||||
def validate_grade_request(data, sport):
|
||||
"""
|
||||
Validate a grade request body.
|
||||
|
||||
Args:
|
||||
data: Request JSON body dict.
|
||||
sport: Sport string ('nba' or 'mlb').
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_data, error_message). One will be None.
|
||||
"""
|
||||
if not data or not isinstance(data, dict):
|
||||
return None, 'Request body must be JSON object'
|
||||
|
||||
if sport not in VALID_SPORTS:
|
||||
return None, f'Invalid sport: {sport}. Must be one of {VALID_SPORTS}'
|
||||
|
||||
player_name = sanitize_string(data.get('player_name', ''), MAX_PLAYER_NAME)
|
||||
if not player_name:
|
||||
return None, 'player_name is required'
|
||||
|
||||
stat_type = sanitize_string(data.get('stat_type', ''), MAX_STAT_TYPE)
|
||||
if stat_type not in VALID_STAT_TYPES.get(sport, []):
|
||||
return None, f'Invalid stat_type for {sport}. Must be one of {VALID_STAT_TYPES[sport]}'
|
||||
|
||||
try:
|
||||
line = float(data.get('line', 0))
|
||||
if line < 0 or line > 500:
|
||||
return None, 'line must be between 0 and 500'
|
||||
except (TypeError, ValueError):
|
||||
return None, 'line must be a number'
|
||||
|
||||
over_under = sanitize_string(data.get('over_under', ''), 10)
|
||||
if over_under not in VALID_OVER_UNDER:
|
||||
return None, f'over_under must be one of {VALID_OVER_UNDER}'
|
||||
|
||||
return {
|
||||
'player_name': player_name,
|
||||
'stat_type': stat_type,
|
||||
'line': line,
|
||||
'over_under': over_under,
|
||||
}, None
|
||||
|
||||
|
||||
def validate_image_upload(file_storage):
|
||||
"""
|
||||
Validate image upload for OCR endpoint.
|
||||
Checks file size (max 10MB) and file type via magic bytes.
|
||||
|
||||
Args:
|
||||
file_storage: Flask FileStorage object.
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_info, error_message).
|
||||
"""
|
||||
if not file_storage:
|
||||
return None, 'No file provided'
|
||||
|
||||
# Check file size
|
||||
file_storage.seek(0, 2)
|
||||
size = file_storage.tell()
|
||||
file_storage.seek(0)
|
||||
if size > 10 * 1024 * 1024:
|
||||
return None, 'File too large (max 10MB)'
|
||||
if size == 0:
|
||||
return None, 'Empty file'
|
||||
|
||||
# Check magic bytes
|
||||
header = file_storage.read(8)
|
||||
file_storage.seek(0)
|
||||
|
||||
valid_signatures = {
|
||||
b'\x89PNG': 'image/png',
|
||||
b'\xff\xd8\xff': 'image/jpeg',
|
||||
b'GIF87a': 'image/gif',
|
||||
b'GIF89a': 'image/gif',
|
||||
}
|
||||
|
||||
file_type = None
|
||||
for sig, mime in valid_signatures.items():
|
||||
if header.startswith(sig):
|
||||
file_type = mime
|
||||
break
|
||||
|
||||
if not file_type:
|
||||
return None, 'Invalid file type. Only PNG, JPEG, GIF accepted.'
|
||||
|
||||
return {'file': file_storage, 'mime_type': file_type, 'size': size}, None
|
||||
|
||||
|
||||
def validate_parlay_request(data):
|
||||
"""
|
||||
Validate parlay grade request.
|
||||
|
||||
Args:
|
||||
data: Request JSON body dict.
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_data, error_message).
|
||||
"""
|
||||
if not data or not isinstance(data, dict):
|
||||
return None, 'Request body must be JSON object'
|
||||
|
||||
legs = data.get('legs', [])
|
||||
if not isinstance(legs, list) or len(legs) < 2:
|
||||
return None, 'Parlay must have at least 2 legs'
|
||||
if len(legs) > 12:
|
||||
return None, 'Maximum 12 legs per parlay'
|
||||
|
||||
for i, leg in enumerate(legs):
|
||||
if not isinstance(leg, dict):
|
||||
return None, f'Leg {i + 1} must be a JSON object'
|
||||
if 'player_name' not in leg or 'stat_type' not in leg:
|
||||
return None, f'Leg {i + 1} missing required fields'
|
||||
|
||||
return data, None
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
VYNDR Weather Monitoring
|
||||
Continuous weather monitoring via Open-Meteo (free, no API key).
|
||||
Includes dome detection, ball carry factor, and regrade triggers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from utils.data_warehouse import fetch_with_cache
|
||||
from utils.retry import api_call_with_retry
|
||||
|
||||
logger = logging.getLogger('vyndr')
|
||||
|
||||
OPEN_METEO_URL = 'https://api.open-meteo.com/v1/forecast'
|
||||
|
||||
WEATHER_MONITORING = {
|
||||
'initial_pull': 'at_lineup_confirmation',
|
||||
'refresh_interval_minutes': 30,
|
||||
'stop_at': 'first_pitch',
|
||||
'regrade_triggers': {
|
||||
'temperature_change_f': 5,
|
||||
'wind_speed_change_mph': 5,
|
||||
'rain_probability_threshold': 0.50,
|
||||
'humidity_change_pct': 15
|
||||
}
|
||||
}
|
||||
|
||||
# Loaded from park_factors.json at boot
|
||||
PARK_COORDINATES = {}
|
||||
|
||||
|
||||
def load_park_coordinates(park_data):
|
||||
"""
|
||||
Load park coordinates from park_factors.json data.
|
||||
|
||||
Args:
|
||||
park_data: Dict loaded from park_factors.json.
|
||||
"""
|
||||
global PARK_COORDINATES
|
||||
if isinstance(park_data, dict):
|
||||
PARK_COORDINATES = park_data
|
||||
elif isinstance(park_data, list):
|
||||
PARK_COORDINATES = {p['park_id']: p for p in park_data}
|
||||
|
||||
|
||||
def get_game_weather(park_id, game_date, game_time):
|
||||
"""
|
||||
Get weather conditions for a game. Skips API call for dome/retractable-closed parks.
|
||||
|
||||
Args:
|
||||
park_id: MLB park identifier.
|
||||
game_date: Game date string (YYYY-MM-DD).
|
||||
game_time: Game time string (HH:MM).
|
||||
|
||||
Returns:
|
||||
Dict with temperature_f, wind_speed_mph, wind_direction, humidity_pct,
|
||||
ball_carry_factor, impact_on_hr, impact_on_scoring, dome_game.
|
||||
"""
|
||||
park = PARK_COORDINATES.get(park_id, {})
|
||||
|
||||
# Dome detection — skip weather for closed/dome parks
|
||||
roof = park.get('roof_status', 'open')
|
||||
if roof in ('dome', 'retractable_closed'):
|
||||
return {
|
||||
'temperature_f': 72, 'wind_speed_mph': 0, 'wind_direction': 'none',
|
||||
'humidity_pct': 50, 'ball_carry_factor': 1.0,
|
||||
'impact_on_hr': 'neutral', 'impact_on_scoring': 'neutral',
|
||||
'dome_game': True
|
||||
}
|
||||
|
||||
def _fetch():
|
||||
params = {
|
||||
'latitude': park.get('lat', 40.0),
|
||||
'longitude': park.get('lng', -74.0),
|
||||
'hourly': 'temperature_2m,windspeed_10m,winddirection_10m,relativehumidity_2m',
|
||||
'temperature_unit': 'fahrenheit',
|
||||
'windspeed_unit': 'mph',
|
||||
'timezone': park.get('timezone', 'America/New_York')
|
||||
}
|
||||
response = requests.get(OPEN_METEO_URL, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
weather = fetch_with_cache(
|
||||
f'weather_{park_id}_{game_date}_{game_time}',
|
||||
_fetch,
|
||||
data_type='weather',
|
||||
has_game_today=True
|
||||
)
|
||||
|
||||
if weather is None:
|
||||
return {
|
||||
'temperature_f': 72, 'wind_speed_mph': 5, 'wind_direction': 'unknown',
|
||||
'humidity_pct': 50, 'ball_carry_factor': 1.0,
|
||||
'impact_on_hr': 'neutral', 'impact_on_scoring': 'neutral',
|
||||
'dome_game': False, '_fallback': True
|
||||
}
|
||||
|
||||
game_hour_data = extract_hour_data(weather, game_time)
|
||||
carry = calculate_ball_carry(game_hour_data)
|
||||
|
||||
return {
|
||||
'temperature_f': game_hour_data.get('temp', 72),
|
||||
'wind_speed_mph': game_hour_data.get('wind_speed', 5),
|
||||
'wind_direction': game_hour_data.get('wind_dir', 'unknown'),
|
||||
'humidity_pct': game_hour_data.get('humidity', 50),
|
||||
'ball_carry_factor': carry,
|
||||
'impact_on_hr': classify_hr_impact(game_hour_data, park_id),
|
||||
'impact_on_scoring': classify_scoring_impact(game_hour_data),
|
||||
'dome_game': False
|
||||
}
|
||||
|
||||
|
||||
def check_weather_for_regrade(park_id, game_date, game_time, previous_weather):
|
||||
"""
|
||||
Check if weather changed enough to trigger re-grade. Called every 30min.
|
||||
|
||||
Args:
|
||||
park_id: MLB park identifier.
|
||||
game_date: Game date string.
|
||||
game_time: Game time string.
|
||||
previous_weather: Previous weather data dict.
|
||||
|
||||
Returns:
|
||||
Dict with needs_regrade (bool), current_weather, and changes list.
|
||||
"""
|
||||
current = get_game_weather(park_id, game_date, game_time)
|
||||
if current.get('dome_game'):
|
||||
return {'needs_regrade': False}
|
||||
|
||||
triggers = WEATHER_MONITORING['regrade_triggers']
|
||||
needs_regrade = False
|
||||
changes = []
|
||||
|
||||
temp_diff = abs(current['temperature_f'] - previous_weather.get('temperature_f', 72))
|
||||
if temp_diff >= triggers['temperature_change_f']:
|
||||
needs_regrade = True
|
||||
changes.append(
|
||||
f"Temp: {previous_weather.get('temperature_f', '?')}"
|
||||
f"→{current['temperature_f']}°F"
|
||||
)
|
||||
|
||||
wind_diff = abs(current['wind_speed_mph'] - previous_weather.get('wind_speed_mph', 5))
|
||||
if wind_diff >= triggers['wind_speed_change_mph']:
|
||||
needs_regrade = True
|
||||
changes.append(
|
||||
f"Wind: {previous_weather.get('wind_speed_mph', '?')}"
|
||||
f"→{current['wind_speed_mph']}mph"
|
||||
)
|
||||
|
||||
return {
|
||||
'needs_regrade': needs_regrade,
|
||||
'current_weather': current,
|
||||
'changes': changes
|
||||
}
|
||||
|
||||
|
||||
def extract_hour_data(weather_data, game_time):
|
||||
"""
|
||||
Extract weather data for the specific game hour from Open-Meteo response.
|
||||
|
||||
Args:
|
||||
weather_data: Full Open-Meteo API response dict.
|
||||
game_time: Game time string (HH:MM).
|
||||
|
||||
Returns:
|
||||
Dict with temp, wind_speed, wind_dir, humidity for the game hour.
|
||||
"""
|
||||
hourly = weather_data.get('hourly', {})
|
||||
times = hourly.get('time', [])
|
||||
|
||||
# Find closest hour
|
||||
target_hour = int(game_time.split(':')[0]) if ':' in str(game_time) else 19
|
||||
best_idx = 0
|
||||
for i, t in enumerate(times):
|
||||
if str(target_hour).zfill(2) in str(t):
|
||||
best_idx = i
|
||||
break
|
||||
|
||||
temps = hourly.get('temperature_2m', [])
|
||||
winds = hourly.get('windspeed_10m', [])
|
||||
wind_dirs = hourly.get('winddirection_10m', [])
|
||||
humidity = hourly.get('relativehumidity_2m', [])
|
||||
|
||||
return {
|
||||
'temp': temps[best_idx] if best_idx < len(temps) else 72,
|
||||
'wind_speed': winds[best_idx] if best_idx < len(winds) else 5,
|
||||
'wind_dir': wind_dirs[best_idx] if best_idx < len(wind_dirs) else 0,
|
||||
'humidity': humidity[best_idx] if best_idx < len(humidity) else 50
|
||||
}
|
||||
|
||||
|
||||
def calculate_ball_carry(weather):
|
||||
"""
|
||||
Calculate ball carry factor based on temperature and humidity.
|
||||
|
||||
Args:
|
||||
weather: Dict with 'temp' and 'humidity' keys.
|
||||
|
||||
Returns:
|
||||
Float ball carry factor (1.0 = neutral).
|
||||
"""
|
||||
temp = weather.get('temp', 72)
|
||||
humidity = weather.get('humidity', 50)
|
||||
temp_factor = 1 + (temp - 72) * 0.002
|
||||
humidity_factor = 1 - (humidity - 50) * 0.001
|
||||
return round(temp_factor * humidity_factor, 3)
|
||||
|
||||
|
||||
def classify_hr_impact(weather, park_id):
|
||||
"""Classify HR impact based on weather conditions."""
|
||||
carry = calculate_ball_carry(weather)
|
||||
wind = weather.get('wind_speed', 0)
|
||||
if carry > 1.02 and wind < 10:
|
||||
return 'favorable'
|
||||
elif carry < 0.98 or wind > 15:
|
||||
return 'unfavorable'
|
||||
return 'neutral'
|
||||
|
||||
|
||||
def classify_scoring_impact(weather):
|
||||
"""Classify overall scoring impact based on weather conditions."""
|
||||
temp = weather.get('temp', 72)
|
||||
wind = weather.get('wind_speed', 0)
|
||||
if temp > 85 and wind < 10:
|
||||
return 'elevated'
|
||||
elif temp < 50 or wind > 15:
|
||||
return 'depressed'
|
||||
return 'neutral'
|
||||
|
||||
|
||||
def check_all_games_weather_regrade():
|
||||
"""
|
||||
Check weather for all today's MLB games and trigger regrade if needed.
|
||||
Called by weather monitoring GitHub Actions cron every 30min.
|
||||
"""
|
||||
logger.info('[VYNDR] Checking weather for all games')
|
||||
# In production: iterate today's MLB games from schedule,
|
||||
# call check_weather_for_regrade for each open-air park
|
||||
Reference in New Issue
Block a user