Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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