Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+319
View File
@@ -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
+121
View File
@@ -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'
+320
View File
@@ -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]
+153
View File
@@ -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}
+123
View File
@@ -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'
}
+87
View File
@@ -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
+63
View File
@@ -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)}
+101
View File
@@ -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]
+201
View File
@@ -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
+186
View File
@@ -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
+240
View File
@@ -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