Biswajeet Arukha commited on
Commit Β·
38e3aca
1
Parent(s): ca15384
Integration of Elite Betting Insights and Pro Dashboard
Browse files- .gitignore +4 -4
- Dockerfile +17 -2
- PUSH_TO_HUB.ps1 +10 -2
- README.md +1 -1
- backend/api/stats_router.py +84 -0
- backend/api_server.py +20 -2
- backend/data_pipeline/consolidate_data.py +73 -0
- backend/data_pipeline/match_discovery.py +38 -0
- backend/ml_engine/hybrid_model.py +4 -2
- backend/requirements.txt +8 -0
- backend/static/pro.html +378 -0
- zai/app.js +595 -0
- zai/betting_metrics.py +499 -0
- zai/index.html +206 -0
- zai/main.py +359 -0
- zai/models.py +145 -0
- zai/styles.css +942 -0
.gitignore
CHANGED
|
@@ -6,10 +6,10 @@ __pycache__/
|
|
| 6 |
.venv312/
|
| 7 |
|
| 8 |
# Data & Datasets
|
| 9 |
-
data/
|
| 10 |
-
dataset/
|
| 11 |
-
|
| 12 |
-
dataset/
|
| 13 |
|
| 14 |
# Large Models (Keep only essential binaries)
|
| 15 |
# models/ and .pth are allowed for repo sync
|
|
|
|
| 6 |
.venv312/
|
| 7 |
|
| 8 |
# Data & Datasets
|
| 9 |
+
data/
|
| 10 |
+
dataset/
|
| 11 |
+
!data/README.txt
|
| 12 |
+
!dataset/sample.csv
|
| 13 |
|
| 14 |
# Large Models (Keep only essential binaries)
|
| 15 |
# models/ and .pth are allowed for repo sync
|
Dockerfile
CHANGED
|
@@ -11,15 +11,30 @@ WORKDIR /app
|
|
| 11 |
# Install system dependencies
|
| 12 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
build-essential \
|
| 14 |
-
libgl1
|
| 15 |
libglib2.0-0 \
|
|
|
|
| 16 |
tesseract-ocr \
|
| 17 |
libtesseract-dev \
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 19 |
|
| 20 |
# Copy requirements and install dependencies
|
| 21 |
COPY backend/requirements.txt .
|
| 22 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
| 23 |
|
| 24 |
# Copy the entire project
|
| 25 |
COPY . .
|
|
|
|
| 11 |
# Install system dependencies
|
| 12 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
build-essential \
|
| 14 |
+
libgl1 \
|
| 15 |
libglib2.0-0 \
|
| 16 |
+
libgomp1 \
|
| 17 |
tesseract-ocr \
|
| 18 |
libtesseract-dev \
|
| 19 |
+
libnss3 \
|
| 20 |
+
libatk1.0-0 \
|
| 21 |
+
libatk-bridge2.0-0 \
|
| 22 |
+
libcups2 \
|
| 23 |
+
libdrm2 \
|
| 24 |
+
libxcomposite1 \
|
| 25 |
+
libxdamage1 \
|
| 26 |
+
libxfixes3 \
|
| 27 |
+
libxrandr2 \
|
| 28 |
+
libgbm1 \
|
| 29 |
+
libpango-1.0-0 \
|
| 30 |
+
libcairo2 \
|
| 31 |
+
libasound2 \
|
| 32 |
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 33 |
|
| 34 |
# Copy requirements and install dependencies
|
| 35 |
COPY backend/requirements.txt .
|
| 36 |
+
RUN pip install --no-cache-dir -r requirements.txt && \
|
| 37 |
+
playwright install chromium
|
| 38 |
|
| 39 |
# Copy the entire project
|
| 40 |
COPY . .
|
PUSH_TO_HUB.ps1
CHANGED
|
@@ -11,8 +11,16 @@ hf auth login
|
|
| 11 |
|
| 12 |
# 3. Push EVERYTHING to your Repo
|
| 13 |
# This includes the api_server.py, models/, and all necessary pipeline code.
|
| 14 |
-
Write-Host "π Uploading Engine & Models to zeroday01/
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
Write-Host "β
Success! Your engine is now synced to the Hub." -ForegroundColor Green
|
| 17 |
|
| 18 |
# π Reminder:
|
|
|
|
| 11 |
|
| 12 |
# 3. Push EVERYTHING to your Repo
|
| 13 |
# This includes the api_server.py, models/, and all necessary pipeline code.
|
| 14 |
+
Write-Host "π Uploading Engine & Models to zeroday01/ipl_prediction_engine..." -ForegroundColor Green
|
| 15 |
+
|
| 16 |
+
# Add new processed data and pro dashboard
|
| 17 |
+
git add data/processed/ipl_matches.csv data/processed/ipl_ball.csv
|
| 18 |
+
git add backend/static/pro.html
|
| 19 |
+
git commit -m "Add consolidated IPL data and pro dashboard"
|
| 20 |
+
git push origin main
|
| 21 |
+
git push space main
|
| 22 |
+
|
| 23 |
+
hf upload zeroday01/ipl_prediction_engine . --exclude ".venv/*" ".venv312/*" ".pytest_cache/*" ".git/*" "__pycache__/*" "*.log" "pytest_*.txt"
|
| 24 |
Write-Host "β
Success! Your engine is now synced to the Hub." -ForegroundColor Green
|
| 25 |
|
| 26 |
# π Reminder:
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: π
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
-
app_port:
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
---
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
---
|
backend/api/stats_router.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import os
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/ipl", tags=["ipl"])
|
| 7 |
+
|
| 8 |
+
# Load CSVs from the processed data directory
|
| 9 |
+
BASE_DIR = "data/processed"
|
| 10 |
+
MATCHES_PATH = os.path.join(BASE_DIR, "ipl_matches.csv")
|
| 11 |
+
BALLS_PATH = os.path.join(BASE_DIR, "ipl_ball.csv")
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
matches_df = pd.read_csv(MATCHES_PATH)
|
| 15 |
+
balls_df = pd.read_csv(BALLS_PATH)
|
| 16 |
+
except Exception as e:
|
| 17 |
+
print(f"β οΈ Warning: Could not load IPL datasets: {e}")
|
| 18 |
+
matches_df = pd.DataFrame()
|
| 19 |
+
balls_df = pd.DataFrame()
|
| 20 |
+
|
| 21 |
+
@router.get("/teams")
|
| 22 |
+
async def get_teams():
|
| 23 |
+
if matches_df.empty:
|
| 24 |
+
return {"teams": []}
|
| 25 |
+
teams = sorted(list(set(matches_df["team1"].unique().tolist() + matches_df["team2"].unique().tolist())))
|
| 26 |
+
return {"teams": teams}
|
| 27 |
+
|
| 28 |
+
@router.get("/players")
|
| 29 |
+
async def get_players(search: Optional[str] = Query(None)):
|
| 30 |
+
if balls_df.empty:
|
| 31 |
+
return {"players": [], "total": 0}
|
| 32 |
+
|
| 33 |
+
batsmen = balls_df["batsman"].unique().tolist()
|
| 34 |
+
bowlers = balls_df["bowler"].unique().tolist()
|
| 35 |
+
all_players = list(set(batsmen + bowlers))
|
| 36 |
+
|
| 37 |
+
if search:
|
| 38 |
+
search_lower = search.lower()
|
| 39 |
+
all_players = [p for p in all_players if search_lower in p.lower()]
|
| 40 |
+
|
| 41 |
+
return {"players": sorted(all_players), "total": len(all_players)}
|
| 42 |
+
|
| 43 |
+
@router.get("/player/{name}")
|
| 44 |
+
async def get_player_stats(name: str):
|
| 45 |
+
if balls_df.empty:
|
| 46 |
+
raise HTTPException(status_code=503, detail="Dataset not loaded")
|
| 47 |
+
|
| 48 |
+
# Batting Stats
|
| 49 |
+
batting = balls_df[balls_df["batsman"] == name]
|
| 50 |
+
if batting.empty and balls_df[balls_df["bowler"] == name].empty:
|
| 51 |
+
raise HTTPException(status_code=404, detail="Player not found")
|
| 52 |
+
|
| 53 |
+
batting_stats = {
|
| 54 |
+
"matches": batting["match_id"].nunique(),
|
| 55 |
+
"runs": int(batting["runs_batter"].sum()),
|
| 56 |
+
"fours": len(batting[batting["runs_batter"] == 4]),
|
| 57 |
+
"sixes": len(batting[batting["runs_batter"] == 6]),
|
| 58 |
+
"strike_rate": round((batting["runs_batter"].sum() / len(batting)) * 100, 2) if len(batting) > 0 else 0
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Bowling Stats
|
| 62 |
+
bowling = balls_df[balls_df["bowler"] == name]
|
| 63 |
+
bowling_stats = {
|
| 64 |
+
"matches": bowling["match_id"].nunique(),
|
| 65 |
+
"wickets": int(bowling[bowling["wicket"] == 1].shape[0]),
|
| 66 |
+
"economy": round((bowling["runs_total"].sum() / (len(bowling) / 6)), 2) if len(bowling) > 0 else 0
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
"player": name,
|
| 71 |
+
"batting": batting_stats,
|
| 72 |
+
"bowling": bowling_stats
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@router.get("/matches")
|
| 76 |
+
async def get_matches(season: Optional[int] = None):
|
| 77 |
+
if matches_df.empty:
|
| 78 |
+
return {"matches": []}
|
| 79 |
+
|
| 80 |
+
df = matches_df.copy()
|
| 81 |
+
if season:
|
| 82 |
+
df = df[df["season"] == season]
|
| 83 |
+
|
| 84 |
+
return {"matches": df.to_dict("records")}
|
backend/api_server.py
CHANGED
|
@@ -15,8 +15,9 @@ from fastapi.responses import FileResponse
|
|
| 15 |
from backend.ml_engine.hybrid_model import RealTimePredictor
|
| 16 |
from backend.ml_engine.agent_sim import MultiAgentSimulator
|
| 17 |
from backend.infrastructure.db_manager import DatabaseManager
|
| 18 |
-
from backend.data_pipeline.match_discovery import MatchDiscoveryService
|
| 19 |
from backend.data_pipeline.espn_scraper import ESPNCricinfoScraper
|
|
|
|
| 20 |
|
| 21 |
# Logging configuration
|
| 22 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
@@ -46,7 +47,7 @@ async def lifespan(app: FastAPI):
|
|
| 46 |
)
|
| 47 |
|
| 48 |
# 3. Load Model from Hugging Face Hub
|
| 49 |
-
HF_REPO = os.getenv("HF_REPO_ID", "zeroday01/
|
| 50 |
predictor = RealTimePredictor(repo_id=HF_REPO)
|
| 51 |
logger.info(f"β
ML Engine successfully loaded from {HF_REPO}")
|
| 52 |
|
|
@@ -85,6 +86,9 @@ app.add_middleware(
|
|
| 85 |
os.makedirs("backend/static", exist_ok=True)
|
| 86 |
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
| 87 |
|
|
|
|
|
|
|
|
|
|
| 88 |
@app.get("/")
|
| 89 |
async def serve_dashboard():
|
| 90 |
return FileResponse("backend/static/index.html")
|
|
@@ -195,6 +199,20 @@ async def health_check():
|
|
| 195 |
|
| 196 |
return res
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
@app.get("/matches")
|
| 199 |
async def list_matches():
|
| 200 |
"""List all active and recent matches in the system"""
|
|
|
|
| 15 |
from backend.ml_engine.hybrid_model import RealTimePredictor
|
| 16 |
from backend.ml_engine.agent_sim import MultiAgentSimulator
|
| 17 |
from backend.infrastructure.db_manager import DatabaseManager
|
| 18 |
+
from backend.data_pipeline.match_discovery import MatchDiscoveryService, IPTLiveFeed
|
| 19 |
from backend.data_pipeline.espn_scraper import ESPNCricinfoScraper
|
| 20 |
+
from backend.api.stats_router import router as stats_router
|
| 21 |
|
| 22 |
# Logging configuration
|
| 23 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
# 3. Load Model from Hugging Face Hub
|
| 50 |
+
HF_REPO = os.getenv("HF_REPO_ID", "zeroday01/ipl_prediction_engine")
|
| 51 |
predictor = RealTimePredictor(repo_id=HF_REPO)
|
| 52 |
logger.info(f"β
ML Engine successfully loaded from {HF_REPO}")
|
| 53 |
|
|
|
|
| 86 |
os.makedirs("backend/static", exist_ok=True)
|
| 87 |
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
| 88 |
|
| 89 |
+
# Include stats router
|
| 90 |
+
app.include_router(stats_router)
|
| 91 |
+
|
| 92 |
@app.get("/")
|
| 93 |
async def serve_dashboard():
|
| 94 |
return FileResponse("backend/static/index.html")
|
|
|
|
| 199 |
|
| 200 |
return res
|
| 201 |
|
| 202 |
+
@app.get("/upcoming/{season}")
|
| 203 |
+
async def upcoming_matches(season: int):
|
| 204 |
+
data = IPTLiveFeed.get_upcoming_matches(season)
|
| 205 |
+
if not data:
|
| 206 |
+
raise HTTPException(status_code=503, detail="Live feed unavailable")
|
| 207 |
+
return data
|
| 208 |
+
|
| 209 |
+
@app.get("/points/{season}")
|
| 210 |
+
async def points_table(season: int):
|
| 211 |
+
data = IPTLiveFeed.get_points_table(season)
|
| 212 |
+
if not data:
|
| 213 |
+
raise HTTPException(status_code=503, detail="Points feed unavailable")
|
| 214 |
+
return data
|
| 215 |
+
|
| 216 |
@app.get("/matches")
|
| 217 |
async def list_matches():
|
| 218 |
"""List all active and recent matches in the system"""
|
backend/data_pipeline/consolidate_data.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from tqdm import tqdm
|
| 5 |
+
|
| 6 |
+
def consolidate_ipl_data(json_folder="data", output_csv="data/processed"):
|
| 7 |
+
all_matches = []
|
| 8 |
+
all_balls = []
|
| 9 |
+
|
| 10 |
+
# Ensure processed directory exists
|
| 11 |
+
Path(output_csv).mkdir(parents=True, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
json_files = list(Path(json_folder).glob("*.json"))
|
| 14 |
+
print(f"π Consolidating {len(json_files)} matches...")
|
| 15 |
+
|
| 16 |
+
for json_file in tqdm(json_files):
|
| 17 |
+
with open(json_file) as f:
|
| 18 |
+
try:
|
| 19 |
+
match = json.load(f)
|
| 20 |
+
except:
|
| 21 |
+
continue
|
| 22 |
+
|
| 23 |
+
# Extract match metadata
|
| 24 |
+
# Some older files might have different ID locations, using filename as fallback
|
| 25 |
+
match_id = json_file.stem
|
| 26 |
+
info = match.get("info", {})
|
| 27 |
+
|
| 28 |
+
match_info = {
|
| 29 |
+
"match_id": match_id,
|
| 30 |
+
"season": info.get("season"),
|
| 31 |
+
"city": info.get("city"),
|
| 32 |
+
"team1": info.get("teams", ["N/A", "N/A"])[0],
|
| 33 |
+
"team2": info.get("teams", ["N/A", "N/A"])[1],
|
| 34 |
+
"winner": info.get("outcome", {}).get("winner"),
|
| 35 |
+
"win_by_runs": info.get("outcome", {}).get("by", {}).get("runs"),
|
| 36 |
+
"win_by_wickets": info.get("outcome", {}).get("by", {}).get("wickets"),
|
| 37 |
+
"date": info.get("dates", [""])[0],
|
| 38 |
+
"toss_winner": info.get("toss", {}).get("winner"),
|
| 39 |
+
"toss_decision": info.get("toss", {}).get("decision")
|
| 40 |
+
}
|
| 41 |
+
all_matches.append(match_info)
|
| 42 |
+
|
| 43 |
+
# Extract ball-by-ball data
|
| 44 |
+
for inning_idx, inning in enumerate(match.get("innings", []), 1):
|
| 45 |
+
batting_team = inning.get("team")
|
| 46 |
+
for over in inning.get("overs", []):
|
| 47 |
+
over_num = over.get("over")
|
| 48 |
+
for ball in over.get("deliveries", []):
|
| 49 |
+
ball_data = {
|
| 50 |
+
"match_id": match_id,
|
| 51 |
+
"inning": inning_idx,
|
| 52 |
+
"batting_team": batting_team,
|
| 53 |
+
"bowling_team": match_info["team2"] if batting_team == match_info["team1"] else match_info["team1"],
|
| 54 |
+
"over": over_num,
|
| 55 |
+
"batsman": ball.get("batter"),
|
| 56 |
+
"bowler": ball.get("bowler"),
|
| 57 |
+
"non_striker": ball.get("non_striker"),
|
| 58 |
+
"runs_batter": ball.get("runs", {}).get("batter", 0),
|
| 59 |
+
"runs_total": ball.get("runs", {}).get("total", 0),
|
| 60 |
+
"extras": ball.get("runs", {}).get("extras", 0),
|
| 61 |
+
"wicket": 1 if "wickets" in ball else 0,
|
| 62 |
+
"player_dismissed": ball.get("wickets", [{}])[0].get("player_out") if "wickets" in ball else None,
|
| 63 |
+
"dismissal_kind": ball.get("wickets", [{}])[0].get("kind") if "wickets" in ball else None
|
| 64 |
+
}
|
| 65 |
+
all_balls.append(ball_data)
|
| 66 |
+
|
| 67 |
+
print(f"πΎ Saving to {output_csv}...")
|
| 68 |
+
pd.DataFrame(all_matches).to_csv(f"{output_csv}/ipl_matches.csv", index=False)
|
| 69 |
+
pd.DataFrame(all_balls).to_csv(f"{output_csv}/ipl_ball.csv", index=False)
|
| 70 |
+
print(f"β
Consolidated {len(all_matches)} matches and {len(all_balls)} ball entries.")
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
consolidate_ipl_data()
|
backend/data_pipeline/match_discovery.py
CHANGED
|
@@ -4,6 +4,9 @@ import json
|
|
| 4 |
import os
|
| 5 |
import redis
|
| 6 |
from typing import List, Dict
|
|
|
|
|
|
|
|
|
|
| 7 |
from scrapling import Fetcher
|
| 8 |
|
| 9 |
# Configure logging
|
|
@@ -14,6 +17,41 @@ logger = logging.getLogger(__name__)
|
|
| 14 |
LIVE_SCORES_URL = "https://www.cricbuzz.com/cricket-match/live-scores"
|
| 15 |
IPL_SERIES_NAME = "Indian Premier League"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
class MatchDiscoveryService:
|
| 18 |
"""
|
| 19 |
Background worker that scans for live IPL matches.
|
|
|
|
| 4 |
import os
|
| 5 |
import redis
|
| 6 |
from typing import List, Dict
|
| 7 |
+
import requests
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from datetime import datetime
|
| 10 |
from scrapling import Fetcher
|
| 11 |
|
| 12 |
# Configure logging
|
|
|
|
| 17 |
LIVE_SCORES_URL = "https://www.cricbuzz.com/cricket-match/live-scores"
|
| 18 |
IPL_SERIES_NAME = "Indian Premier League"
|
| 19 |
|
| 20 |
+
class IPTLiveFeed:
|
| 21 |
+
BASE_URL = "https://ipl-stats-sports-mechanic.s3.ap-south-1.amazonaws.com/ipl/feeds/"
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def get_upcoming_matches(season):
|
| 25 |
+
url = f"{IPTLiveFeed.BASE_URL}{season}-matchschedule.js?"
|
| 26 |
+
try:
|
| 27 |
+
resp = requests.get(url)
|
| 28 |
+
if resp.status_code != 200:
|
| 29 |
+
# Fallback to Cricbuzz scraping (implement as needed)
|
| 30 |
+
return None
|
| 31 |
+
return resp.json()
|
| 32 |
+
except requests.RequestException as e:
|
| 33 |
+
logger.error(f"Error fetching upcoming matches: {e}")
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def get_points_table(season):
|
| 38 |
+
url = f"{IPTLiveFeed.BASE_URL}stats/{season}-groupstandings.js?"
|
| 39 |
+
try:
|
| 40 |
+
resp = requests.get(url)
|
| 41 |
+
if resp.status_code != 200:
|
| 42 |
+
return None
|
| 43 |
+
return resp.json()
|
| 44 |
+
except requests.RequestException as e:
|
| 45 |
+
logger.error(f"Error fetching points table: {e}")
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def trigger_retraining(new_match_id):
|
| 49 |
+
# Download new match JSON, run consolidate_data.py incrementally
|
| 50 |
+
# Then retrain your XGBoost+LSTM model
|
| 51 |
+
logger.info(f"Triggering retraining for new match completion: {new_match_id}")
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
|
| 55 |
class MatchDiscoveryService:
|
| 56 |
"""
|
| 57 |
Background worker that scans for live IPL matches.
|
backend/ml_engine/hybrid_model.py
CHANGED
|
@@ -840,7 +840,9 @@ class HybridEnsemble:
|
|
| 840 |
os.makedirs(os.path.dirname(path_prefix), exist_ok=True)
|
| 841 |
|
| 842 |
for file_name in files_to_download:
|
| 843 |
-
|
|
|
|
|
|
|
| 844 |
# Copy to our expected path_prefix location
|
| 845 |
target_name = f"{path_prefix}_{file_name.split('_')[-1]}"
|
| 846 |
import shutil
|
|
@@ -857,7 +859,7 @@ class HybridEnsemble:
|
|
| 857 |
|
| 858 |
# Load LSTM
|
| 859 |
self.lstm_model = self.build_lstm_model()
|
| 860 |
-
self.lstm_model.load_state_dict(torch.load(f"{path_prefix}_lstm.pth"))
|
| 861 |
self.lstm_model.eval()
|
| 862 |
|
| 863 |
# Load scalers
|
|
|
|
| 840 |
os.makedirs(os.path.dirname(path_prefix), exist_ok=True)
|
| 841 |
|
| 842 |
for file_name in files_to_download:
|
| 843 |
+
# We must specify the folder path in the repository
|
| 844 |
+
repo_file_path = f"models/{file_name}"
|
| 845 |
+
local_path = hf_hub_download(repo_id=repo_id, filename=repo_file_path)
|
| 846 |
# Copy to our expected path_prefix location
|
| 847 |
target_name = f"{path_prefix}_{file_name.split('_')[-1]}"
|
| 848 |
import shutil
|
|
|
|
| 859 |
|
| 860 |
# Load LSTM
|
| 861 |
self.lstm_model = self.build_lstm_model()
|
| 862 |
+
self.lstm_model.load_state_dict(torch.load(f"{path_prefix}_lstm.pth", map_location=self.device))
|
| 863 |
self.lstm_model.eval()
|
| 864 |
|
| 865 |
# Load scalers
|
backend/requirements.txt
CHANGED
|
@@ -59,6 +59,14 @@ fastparquet>=2023.7.0
|
|
| 59 |
orjson>=3.9.0
|
| 60 |
ujson>=5.8.0
|
| 61 |
msgpack>=1.0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
# Logging & Debugging
|
| 64 |
structlog>=23.0.0
|
|
|
|
| 59 |
orjson>=3.9.0
|
| 60 |
ujson>=5.8.0
|
| 61 |
msgpack>=1.0.5
|
| 62 |
+
openai
|
| 63 |
+
uvloop
|
| 64 |
+
curl_cffi
|
| 65 |
+
mlflow
|
| 66 |
+
joblib
|
| 67 |
+
nvidia-ml-py
|
| 68 |
+
browserforge
|
| 69 |
+
lxml
|
| 70 |
|
| 71 |
# Logging & Debugging
|
| 72 |
structlog>=23.0.0
|
backend/static/pro.html
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>IPL Pro Analytics Dashboard</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg-dark: #0f172a;
|
| 10 |
+
--panel-bg: rgba(255, 255, 255, 0.05);
|
| 11 |
+
--text-main: #f8fafc;
|
| 12 |
+
--text-muted: #94a3b8;
|
| 13 |
+
--accent: #3b82f6;
|
| 14 |
+
--accent-hover: #2563eb;
|
| 15 |
+
--border: rgba(255, 255, 255, 0.1);
|
| 16 |
+
--win: #10b981;
|
| 17 |
+
--loss: #ef4444;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
* {
|
| 21 |
+
box-sizing: border-box;
|
| 22 |
+
margin: 0;
|
| 23 |
+
padding: 0;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 28 |
+
background-color: var(--bg-dark);
|
| 29 |
+
color: var(--text-main);
|
| 30 |
+
line-height: 1.6;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.container {
|
| 34 |
+
max-width: 1200px;
|
| 35 |
+
margin: 0 auto;
|
| 36 |
+
padding: 2rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* Tabs */
|
| 40 |
+
.tabs {
|
| 41 |
+
display: flex;
|
| 42 |
+
gap: 1rem;
|
| 43 |
+
margin-bottom: 2rem;
|
| 44 |
+
border-bottom: 1px solid var(--border);
|
| 45 |
+
padding-bottom: 1rem;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.tablink {
|
| 49 |
+
background: transparent;
|
| 50 |
+
color: var(--text-muted);
|
| 51 |
+
border: none;
|
| 52 |
+
padding: 0.75rem 1.5rem;
|
| 53 |
+
font-size: 1rem;
|
| 54 |
+
font-weight: 600;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
border-radius: 6px;
|
| 57 |
+
transition: all 0.2s;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.tablink:hover {
|
| 61 |
+
color: var(--text-main);
|
| 62 |
+
background: var(--panel-bg);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.tablink.active {
|
| 66 |
+
color: #fff;
|
| 67 |
+
background: var(--accent);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.tabcontent {
|
| 71 |
+
display: none;
|
| 72 |
+
animation: fadeIn 0.3s ease-in-out;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.tabcontent.active {
|
| 76 |
+
display: block;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* Panels */
|
| 80 |
+
.panel {
|
| 81 |
+
background: var(--panel-bg);
|
| 82 |
+
border: 1px solid var(--border);
|
| 83 |
+
border-radius: 12px;
|
| 84 |
+
padding: 1.5rem;
|
| 85 |
+
margin-bottom: 1.5rem;
|
| 86 |
+
backdrop-filter: blur(10px);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* Forms & Inputs */
|
| 90 |
+
input[type="text"] {
|
| 91 |
+
width: 100%;
|
| 92 |
+
padding: 1rem;
|
| 93 |
+
background: rgba(0, 0, 0, 0.2);
|
| 94 |
+
border: 1px solid var(--border);
|
| 95 |
+
color: white;
|
| 96 |
+
border-radius: 8px;
|
| 97 |
+
font-size: 1rem;
|
| 98 |
+
margin-bottom: 1rem;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
input[type="text"]:focus {
|
| 102 |
+
outline: none;
|
| 103 |
+
border-color: var(--accent);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Tables & Lists */
|
| 107 |
+
table {
|
| 108 |
+
width: 100%;
|
| 109 |
+
border-collapse: collapse;
|
| 110 |
+
margin-top: 1rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
th, td {
|
| 114 |
+
padding: 1rem;
|
| 115 |
+
text-align: left;
|
| 116 |
+
border-bottom: 1px solid var(--border);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
th {
|
| 120 |
+
color: var(--text-muted);
|
| 121 |
+
font-weight: 600;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* Utilities */
|
| 125 |
+
h2, h3 { margin-bottom: 1rem; }
|
| 126 |
+
.text-accent { color: var(--accent); }
|
| 127 |
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
| 128 |
+
|
| 129 |
+
@keyframes fadeIn {
|
| 130 |
+
from { opacity: 0; transform: translateY(5px); }
|
| 131 |
+
to { opacity: 1; transform: translateY(0); }
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
#autocomplete-results {
|
| 135 |
+
max-height: 200px;
|
| 136 |
+
overflow-y: auto;
|
| 137 |
+
background: #1e293b;
|
| 138 |
+
border: 1px solid var(--border);
|
| 139 |
+
border-radius: 8px;
|
| 140 |
+
margin-top: -0.5rem;
|
| 141 |
+
margin-bottom: 1rem;
|
| 142 |
+
position: absolute;
|
| 143 |
+
width: calc(100% - 3rem);
|
| 144 |
+
z-index: 10;
|
| 145 |
+
display: none;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.autocomplete-item {
|
| 149 |
+
padding: 0.75rem 1rem;
|
| 150 |
+
cursor: pointer;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.autocomplete-item:hover {
|
| 154 |
+
background: var(--accent);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.stats-card {
|
| 158 |
+
background: rgba(0,0,0,0.2);
|
| 159 |
+
padding: 1rem;
|
| 160 |
+
border-radius: 8px;
|
| 161 |
+
text-align: center;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.stats-val {
|
| 165 |
+
font-size: 1.5rem;
|
| 166 |
+
font-weight: bold;
|
| 167 |
+
color: var(--accent);
|
| 168 |
+
}
|
| 169 |
+
</style>
|
| 170 |
+
</head>
|
| 171 |
+
<body>
|
| 172 |
+
|
| 173 |
+
<div class="container">
|
| 174 |
+
<header style="margin-bottom: 2rem;">
|
| 175 |
+
<h1><span class="text-accent">IPL</span> Pro Analytics</h1>
|
| 176 |
+
<p style="color: var(--text-muted);">Real-time Insights & Historical Intelligence</p>
|
| 177 |
+
</header>
|
| 178 |
+
|
| 179 |
+
<div class="tabs">
|
| 180 |
+
<button class="tablink active" onclick="openTab(event, 'live')">Live Betting</button>
|
| 181 |
+
<button class="tablink" onclick="openTab(event, 'schedule')">Schedule & Points</button>
|
| 182 |
+
<button class="tablink" onclick="openTab(event, 'analysis')">Player Analysis</button>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<!-- Live Tab -->
|
| 186 |
+
<div id="live" class="tabcontent active">
|
| 187 |
+
<div class="panel">
|
| 188 |
+
<h2>Live Match Intelligence</h2>
|
| 189 |
+
<p style="color: var(--text-muted); margin-bottom: 1rem;">Waiting for active match data...</p>
|
| 190 |
+
<div id="live-odds-container" class="grid-2">
|
| 191 |
+
<!-- Data will be injected here via websocket in future updates -->
|
| 192 |
+
</div>
|
| 193 |
+
<a href="/" style="color: var(--accent); text-decoration: none;">← Back to Classic Dashboard</a>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Schedule Tab -->
|
| 198 |
+
<div id="schedule" class="tabcontent">
|
| 199 |
+
<div class="grid-2">
|
| 200 |
+
<div class="panel">
|
| 201 |
+
<h3>Upcoming Matches (2024/2025)</h3>
|
| 202 |
+
<div id="upcoming-container">Loading schedule...</div>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="panel">
|
| 205 |
+
<h3>Points Table</h3>
|
| 206 |
+
<div id="points-container">Loading points...</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- Analysis Tab -->
|
| 212 |
+
<div id="analysis" class="tabcontent">
|
| 213 |
+
<div class="panel" style="position: relative;">
|
| 214 |
+
<h3>Search Player Database</h3>
|
| 215 |
+
<input type="text" id="player-search" placeholder="Type player name (e.g., Virat Kohli)" onkeyup="autocompletePlayer()" autocomplete="off">
|
| 216 |
+
<div id="autocomplete-results"></div>
|
| 217 |
+
|
| 218 |
+
<div id="player-stats-container" style="display: none; margin-top: 2rem;">
|
| 219 |
+
<h2 id="ps-name" class="text-accent" style="font-size: 2rem; margin-bottom: 1.5rem;"></h2>
|
| 220 |
+
|
| 221 |
+
<div class="grid-2">
|
| 222 |
+
<div>
|
| 223 |
+
<h4 style="margin-bottom: 1rem; color: var(--text-muted);">BATTING</h4>
|
| 224 |
+
<div class="grid-2" style="gap: 1rem;">
|
| 225 |
+
<div class="stats-card"><div>Runs</div><div class="stats-val" id="ps-runs">0</div></div>
|
| 226 |
+
<div class="stats-card"><div>Strike Rate</div><div class="stats-val" id="ps-sr">0</div></div>
|
| 227 |
+
<div class="stats-card"><div>Fours</div><div class="stats-val" id="ps-4s">0</div></div>
|
| 228 |
+
<div class="stats-card"><div>Sixes</div><div class="stats-val" id="ps-6s">0</div></div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
<div>
|
| 232 |
+
<h4 style="margin-bottom: 1rem; color: var(--text-muted);">BOWLING</h4>
|
| 233 |
+
<div class="grid-2" style="gap: 1rem;">
|
| 234 |
+
<div class="stats-card"><div>Wickets</div><div class="stats-val" id="ps-wkts">0</div></div>
|
| 235 |
+
<div class="stats-card"><div>Economy</div><div class="stats-val" id="ps-econ">0</div></div>
|
| 236 |
+
<div class="stats-card"><div>Matches</div><div class="stats-val" id="ps-bmat">0</div></div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<script>
|
| 246 |
+
// Tab Logic
|
| 247 |
+
function openTab(evt, tabName) {
|
| 248 |
+
var i, tabcontent, tablinks;
|
| 249 |
+
tabcontent = document.getElementsByClassName("tabcontent");
|
| 250 |
+
for (i = 0; i < tabcontent.length; i++) {
|
| 251 |
+
tabcontent[i].style.display = "none";
|
| 252 |
+
tabcontent[i].classList.remove("active");
|
| 253 |
+
}
|
| 254 |
+
tablinks = document.getElementsByClassName("tablink");
|
| 255 |
+
for (i = 0; i < tablinks.length; i++) {
|
| 256 |
+
tablinks[i].classList.remove("active");
|
| 257 |
+
}
|
| 258 |
+
document.getElementById(tabName).style.display = "block";
|
| 259 |
+
document.getElementById(tabName).classList.add("active");
|
| 260 |
+
evt.currentTarget.classList.add("active");
|
| 261 |
+
|
| 262 |
+
// Load data if switching to specific tabs
|
| 263 |
+
if(tabName === 'schedule') loadSchedule(2024); // Fallback to 2024 as 2025 feed might not be live yet
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Schedule & Points Logic
|
| 267 |
+
async function loadSchedule(season) {
|
| 268 |
+
try {
|
| 269 |
+
const upContainer = document.getElementById('upcoming-container');
|
| 270 |
+
const ptContainer = document.getElementById('points-container');
|
| 271 |
+
|
| 272 |
+
const [upcomingRes, pointsRes] = await Promise.all([
|
| 273 |
+
fetch(`/upcoming/${season}`),
|
| 274 |
+
fetch(`/points/${season}`)
|
| 275 |
+
]);
|
| 276 |
+
|
| 277 |
+
if (upcomingRes.ok) {
|
| 278 |
+
const upcoming = await upcomingRes.json();
|
| 279 |
+
if(upcoming && upcoming.matchschedule) {
|
| 280 |
+
let html = '<table><tr><th>Date</th><th>Match</th><th>Venue</th></tr>';
|
| 281 |
+
upcoming.matchschedule.slice(0, 10).forEach(m => { // Show next 10
|
| 282 |
+
html += `<tr><td>${m.matchdate}</td><td>${m.teama} vs ${m.teamb}</td><td>${m.venue}</td></tr>`;
|
| 283 |
+
});
|
| 284 |
+
html += '</table>';
|
| 285 |
+
upContainer.innerHTML = html;
|
| 286 |
+
} else {
|
| 287 |
+
upContainer.innerHTML = 'No schedule data available.';
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
if(pointsRes.ok) {
|
| 292 |
+
const points = await pointsRes.json();
|
| 293 |
+
if(points && points.points) {
|
| 294 |
+
let html = '<table><tr><th>Team</th><th>M</th><th>W</th><th>L</th><th>Pts</th><th>NRR</th></tr>';
|
| 295 |
+
points.points.forEach(p => {
|
| 296 |
+
html += `<tr><td><b>${p.teamshortname}</b></td><td>${p.matchesplayed}</td><td>${p.matcheswon}</td><td>${p.matcheslost}</td><td class="text-accent"><b>${p.points}</b></td><td>${p.nrr}</td></tr>`;
|
| 297 |
+
});
|
| 298 |
+
html += '</table>';
|
| 299 |
+
ptContainer.innerHTML = html;
|
| 300 |
+
} else {
|
| 301 |
+
ptContainer.innerHTML = 'No points table data available.';
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
} catch (e) {
|
| 306 |
+
console.error("Error loading schedule data:", e);
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Player Analysis Autocomplete
|
| 311 |
+
let searchTimeout;
|
| 312 |
+
async function autocompletePlayer() {
|
| 313 |
+
const input = document.getElementById('player-search').value;
|
| 314 |
+
const resultsDiv = document.getElementById('autocomplete-results');
|
| 315 |
+
|
| 316 |
+
if (input.length < 3) {
|
| 317 |
+
resultsDiv.style.display = 'none';
|
| 318 |
+
return;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
clearTimeout(searchTimeout);
|
| 322 |
+
searchTimeout = setTimeout(async () => {
|
| 323 |
+
try {
|
| 324 |
+
const response = await fetch(`/ipl/players?search=${encodeURIComponent(input)}`);
|
| 325 |
+
const data = await response.json();
|
| 326 |
+
|
| 327 |
+
if (data.players && data.players.length > 0) {
|
| 328 |
+
let html = '';
|
| 329 |
+
data.players.slice(0, 10).forEach(p => {
|
| 330 |
+
html += `<div class="autocomplete-item" onclick="selectPlayer('${p.replace(/'/g, "\\'")}')">${p}</div>`;
|
| 331 |
+
});
|
| 332 |
+
resultsDiv.innerHTML = html;
|
| 333 |
+
resultsDiv.style.display = 'block';
|
| 334 |
+
} else {
|
| 335 |
+
resultsDiv.style.display = 'none';
|
| 336 |
+
}
|
| 337 |
+
} catch (e) {
|
| 338 |
+
console.error("Search error", e);
|
| 339 |
+
}
|
| 340 |
+
}, 300);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async function selectPlayer(name) {
|
| 344 |
+
document.getElementById('player-search').value = name;
|
| 345 |
+
document.getElementById('autocomplete-results').style.display = 'none';
|
| 346 |
+
|
| 347 |
+
try {
|
| 348 |
+
const response = await fetch(`/ipl/player/${encodeURIComponent(name)}`);
|
| 349 |
+
if (response.ok) {
|
| 350 |
+
const data = await response.json();
|
| 351 |
+
|
| 352 |
+
document.getElementById('ps-name').innerText = data.player;
|
| 353 |
+
document.getElementById('ps-runs').innerText = data.batting.runs;
|
| 354 |
+
document.getElementById('ps-sr').innerText = data.batting.strike_rate;
|
| 355 |
+
document.getElementById('ps-4s').innerText = data.batting.fours;
|
| 356 |
+
document.getElementById('ps-6s').innerText = data.batting.sixes;
|
| 357 |
+
|
| 358 |
+
document.getElementById('ps-wkts').innerText = data.bowling.wickets;
|
| 359 |
+
document.getElementById('ps-econ').innerText = data.bowling.economy;
|
| 360 |
+
document.getElementById('ps-bmat').innerText = data.bowling.matches;
|
| 361 |
+
|
| 362 |
+
document.getElementById('player-stats-container').style.display = 'block';
|
| 363 |
+
}
|
| 364 |
+
} catch (e) {
|
| 365 |
+
console.error("Error loading player stats", e);
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// Close autocomplete when clicking outside
|
| 370 |
+
document.addEventListener('click', function(e) {
|
| 371 |
+
if (e.target.id !== 'player-search') {
|
| 372 |
+
document.getElementById('autocomplete-results').style.display = 'none';
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
</script>
|
| 376 |
+
|
| 377 |
+
</body>
|
| 378 |
+
</html>
|
zai/app.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
* IPL Pro-Analytics β Real-Time Dashboard Controller
|
| 4 |
+
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
*
|
| 6 |
+
* Architecture
|
| 7 |
+
* ββββββββββββ
|
| 8 |
+
* 1. State Store β single source of truth for all dashboard data
|
| 9 |
+
* 2. WebSocket Client β reconnecting WS with exponential backoff
|
| 10 |
+
* 3. REST Fallback β fetch /predict when WS unavailable
|
| 11 |
+
* 4. Render Pipeline β DOM diffing strategy: only update changed values
|
| 12 |
+
* 5. Animation Engine β CSS-class-driven flash effects
|
| 13 |
+
*
|
| 14 |
+
* Performance
|
| 15 |
+
* βββββββββββ
|
| 16 |
+
* β’ requestAnimationFrame throttled re-renders
|
| 17 |
+
* β’ No innerHTML in hot path β uses textContent / classList API
|
| 18 |
+
* β’ Debounced prop-bet heatmap (500 ms)
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
(function () {
|
| 22 |
+
'use strict';
|
| 23 |
+
|
| 24 |
+
// βββ Configuration βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
const CONFIG = {
|
| 26 |
+
WS_BASE: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`,
|
| 27 |
+
API_BASE: location.origin,
|
| 28 |
+
WS_RECONNECT_BASE_MS: 1000,
|
| 29 |
+
WS_RECONNECT_MAX_MS: 30000,
|
| 30 |
+
WS_TICK_INTERVAL_MS: 2000,
|
| 31 |
+
PROP_UPDATE_MS: 5000,
|
| 32 |
+
REST_POLL_MS: 10000,
|
| 33 |
+
MATCH_ID: 'MIvCSK', // Default
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// βββ State Store βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
const state = {
|
| 38 |
+
matchId: CONFIG.MATCH_ID,
|
| 39 |
+
wsConnected: false,
|
| 40 |
+
// Betting Metrics
|
| 41 |
+
odds: null,
|
| 42 |
+
volatility: null,
|
| 43 |
+
overUnder: [],
|
| 44 |
+
wicketMethods: [],
|
| 45 |
+
propBets: [],
|
| 46 |
+
// Scorecard
|
| 47 |
+
scorecard: { runs: 0, wickets: 0, overs: '0.0', crr: '0.00', phase: 'β', projected: 'β' },
|
| 48 |
+
// Meta
|
| 49 |
+
sharpEdge: 'β',
|
| 50 |
+
blendWeights: 'XGB 60% Β· LSTM 40%',
|
| 51 |
+
lastUpdateMs: 0,
|
| 52 |
+
latencyMs: null,
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// βββ DOM Cache βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
const $ = (sel) => document.querySelector(sel);
|
| 57 |
+
const dom = {
|
| 58 |
+
// Odds
|
| 59 |
+
oddsHome: $('#odds-home'),
|
| 60 |
+
oddsAway: $('#odds-away'),
|
| 61 |
+
impliedHome: $('#implied-home'),
|
| 62 |
+
impliedAway: $('#implied-away'),
|
| 63 |
+
barHome: $('#bar-home'),
|
| 64 |
+
barAway: $('#bar-away'),
|
| 65 |
+
overround: $('#overround'),
|
| 66 |
+
sharpEdge: $('#sharp-edge'),
|
| 67 |
+
blendWeights: $('#blend-weights'),
|
| 68 |
+
// Volatility
|
| 69 |
+
gaugeArc: $('#gauge-arc'),
|
| 70 |
+
gaugeValue: $('#gauge-value'),
|
| 71 |
+
gaugeLabel: $('#gauge-label'),
|
| 72 |
+
tickVelocity: $('#tick-velocity'),
|
| 73 |
+
maxShift: $('#max-shift'),
|
| 74 |
+
deltaLabel: $('#delta-label'),
|
| 75 |
+
// Over/Under
|
| 76 |
+
ouTbody: $('#ou-tbody'),
|
| 77 |
+
ouUpdated: $('#ou-updated'),
|
| 78 |
+
// Wicket
|
| 79 |
+
wicketMethods:$('#wicket-methods'),
|
| 80 |
+
// Heatmap
|
| 81 |
+
heatmapGrid: $('#heatmap-grid'),
|
| 82 |
+
// Scorecard
|
| 83 |
+
scoreRuns: $('#score-runs'),
|
| 84 |
+
scoreWickets: $('#score-wickets'),
|
| 85 |
+
scoreOvers: $('#score-overs'),
|
| 86 |
+
statCrr: $('#stat-crr'),
|
| 87 |
+
statPhase: $('#stat-phase'),
|
| 88 |
+
statProjected:$('#stat-projected'),
|
| 89 |
+
// Connection
|
| 90 |
+
connBadge: $('#conn-badge'),
|
| 91 |
+
connDot: null,
|
| 92 |
+
connText: null,
|
| 93 |
+
// Footer
|
| 94 |
+
footerLatency:$('#footer-latency'),
|
| 95 |
+
// Match Select
|
| 96 |
+
matchSelect: $('#match-select'),
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// βββ Utility Functions βββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
function fmt(n, decimals = 2) {
|
| 101 |
+
return Number(n).toFixed(decimals);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function pct(n) {
|
| 105 |
+
return (n * 100).toFixed(1) + '%';
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function timeAgo(ms) {
|
| 109 |
+
const diff = Date.now() - ms;
|
| 110 |
+
if (diff < 2000) return 'just now';
|
| 111 |
+
if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
|
| 112 |
+
return Math.floor(diff / 60000) + 'm ago';
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function heatmapClass(prob) {
|
| 116 |
+
if (prob >= 0.80) return 'prob-very-high';
|
| 117 |
+
if (prob >= 0.60) return 'prob-high';
|
| 118 |
+
if (prob >= 0.40) return 'prob-medium';
|
| 119 |
+
if (prob >= 0.20) return 'prob-low';
|
| 120 |
+
return 'prob-very-low';
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function volatilityColor(score) {
|
| 124 |
+
if (score > 0.75) return '#ff3366';
|
| 125 |
+
if (score > 0.50) return '#ff8800';
|
| 126 |
+
if (score > 0.25) return '#ffd700';
|
| 127 |
+
return '#00ff88';
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// βββ Connection Badge ββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
function initConnBadge() {
|
| 132 |
+
dom.connDot = dom.connBadge.querySelector('.conn-dot');
|
| 133 |
+
dom.connText = dom.connBadge.querySelector('.conn-text');
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function setConnStatus(status) {
|
| 137 |
+
dom.connBadge.className = 'connection-badge ' + status;
|
| 138 |
+
if (status === 'connected') {
|
| 139 |
+
dom.connText.textContent = 'Live';
|
| 140 |
+
} else if (status === 'disconnected') {
|
| 141 |
+
dom.connText.textContent = 'Offline';
|
| 142 |
+
} else {
|
| 143 |
+
dom.connText.textContent = 'Connectingβ¦';
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// βββ Render: Live Odds βββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
+
let prevOddsHome = null;
|
| 149 |
+
let prevOddsAway = null;
|
| 150 |
+
|
| 151 |
+
function renderOdds(odds) {
|
| 152 |
+
if (!odds) return;
|
| 153 |
+
|
| 154 |
+
const homeStr = fmt(odds.home, 3);
|
| 155 |
+
const awayStr = fmt(odds.away, 3);
|
| 156 |
+
|
| 157 |
+
dom.oddsHome.textContent = homeStr;
|
| 158 |
+
dom.oddsAway.textContent = awayStr;
|
| 159 |
+
dom.impliedHome.textContent = 'Implied: ' + pct(odds.implied_home);
|
| 160 |
+
dom.impliedAway.textContent = 'Implied: ' + pct(odds.implied_away);
|
| 161 |
+
|
| 162 |
+
// Probability bars
|
| 163 |
+
const homePct = odds.implied_home * 100;
|
| 164 |
+
dom.barHome.style.width = homePct + '%';
|
| 165 |
+
dom.barAway.style.width = (100 - homePct) + '%';
|
| 166 |
+
|
| 167 |
+
// Flash on change
|
| 168 |
+
if (prevOddsHome !== null) {
|
| 169 |
+
if (odds.home > prevOddsHome + 0.005) {
|
| 170 |
+
dom.oddsHome.classList.remove('odds-flash-up', 'odds-flash-down');
|
| 171 |
+
void dom.oddsHome.offsetWidth; // Force reflow
|
| 172 |
+
dom.oddsHome.classList.add('odds-flash-up');
|
| 173 |
+
} else if (odds.home < prevOddsHome - 0.005) {
|
| 174 |
+
dom.oddsHome.classList.remove('odds-flash-up', 'odds-flash-down');
|
| 175 |
+
void dom.oddsHome.offsetWidth;
|
| 176 |
+
dom.oddsHome.classList.add('odds-flash-down');
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
prevOddsHome = odds.home;
|
| 181 |
+
prevOddsAway = odds.away;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// βββ Render: Volatility Gauge ββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
function renderVolatility(vol) {
|
| 186 |
+
if (!vol) return;
|
| 187 |
+
|
| 188 |
+
// SVG arc: total path length ~251.3
|
| 189 |
+
const arcLength = 251.3;
|
| 190 |
+
const filled = arcLength * vol.score;
|
| 191 |
+
dom.gaugeArc.setAttribute('stroke-dasharray', `${filled} ${arcLength}`);
|
| 192 |
+
|
| 193 |
+
// Colour
|
| 194 |
+
const color = volatilityColor(vol.score);
|
| 195 |
+
dom.gaugeArc.setAttribute('stroke', color);
|
| 196 |
+
|
| 197 |
+
// Text
|
| 198 |
+
dom.gaugeValue.textContent = fmt(vol.score, 2);
|
| 199 |
+
dom.gaugeLabel.textContent = vol.label;
|
| 200 |
+
dom.gaugeLabel.setAttribute('fill', color);
|
| 201 |
+
|
| 202 |
+
// Stats
|
| 203 |
+
dom.tickVelocity.textContent = vol.tick_velocity.toFixed(1);
|
| 204 |
+
dom.maxShift.textContent = fmt(vol.max_shift_pct, 1) + '%';
|
| 205 |
+
dom.deltaLabel.textContent = state._deltaLabel || 'β';
|
| 206 |
+
|
| 207 |
+
// Colour delta
|
| 208 |
+
const dl = state._deltaLabel;
|
| 209 |
+
if (dl === 'β') dom.deltaLabel.style.color = '#00ff88';
|
| 210 |
+
else if (dl === 'β') dom.deltaLabel.style.color = '#ff3366';
|
| 211 |
+
else dom.deltaLabel.style.color = '#8b8fa3';
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// βββ Render: Over/Under Table ββββββββββββββββββββββββββββββββββββ
|
| 215 |
+
function renderOverUnder(lines) {
|
| 216 |
+
if (!lines || lines.length === 0) return;
|
| 217 |
+
|
| 218 |
+
const frag = document.createDocumentFragment();
|
| 219 |
+
|
| 220 |
+
lines.forEach(line => {
|
| 221 |
+
const tr = document.createElement('tr');
|
| 222 |
+
|
| 223 |
+
// Market
|
| 224 |
+
const tdMarket = document.createElement('td');
|
| 225 |
+
tdMarket.textContent = line.market;
|
| 226 |
+
tr.appendChild(tdMarket);
|
| 227 |
+
|
| 228 |
+
// Line
|
| 229 |
+
const tdLine = document.createElement('td');
|
| 230 |
+
tdLine.textContent = fmt(line.line, 1);
|
| 231 |
+
tdLine.style.color = 'var(--gold)';
|
| 232 |
+
tdLine.style.fontWeight = '700';
|
| 233 |
+
tr.appendChild(tdLine);
|
| 234 |
+
|
| 235 |
+
// Over Odds
|
| 236 |
+
const tdOver = document.createElement('td');
|
| 237 |
+
tdOver.textContent = fmt(line.over_odds, 2);
|
| 238 |
+
tdOver.style.color = 'var(--neon-green)';
|
| 239 |
+
tr.appendChild(tdOver);
|
| 240 |
+
|
| 241 |
+
// Under Odds
|
| 242 |
+
const tdUnder = document.createElement('td');
|
| 243 |
+
tdUnder.textContent = fmt(line.under_odds, 2);
|
| 244 |
+
tdUnder.style.color = 'var(--neon-green)';
|
| 245 |
+
tr.appendChild(tdUnder);
|
| 246 |
+
|
| 247 |
+
// Projected
|
| 248 |
+
const tdProj = document.createElement('td');
|
| 249 |
+
tdProj.textContent = fmt(line.projected_total, 1);
|
| 250 |
+
tdProj.style.color = 'var(--text-secondary)';
|
| 251 |
+
tr.appendChild(tdProj);
|
| 252 |
+
|
| 253 |
+
// Confidence
|
| 254 |
+
const tdConf = document.createElement('td');
|
| 255 |
+
const confPct = line.confidence * 100;
|
| 256 |
+
tdConf.innerHTML = `<span style="color:${confPct > 65 ? 'var(--neon-green)' : 'var(--gold)'}">${confPct.toFixed(0)}%</span>`;
|
| 257 |
+
tr.appendChild(tdConf);
|
| 258 |
+
|
| 259 |
+
frag.appendChild(tr);
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
dom.ouTbody.innerHTML = '';
|
| 263 |
+
dom.ouTbody.appendChild(frag);
|
| 264 |
+
dom.ouUpdated.textContent = timeAgo(state.lastUpdateMs);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// βββ Render: Wicket Methods ββββββββββββββββββββββββββββββββββββββ
|
| 268 |
+
function renderWicketMethods(methods) {
|
| 269 |
+
if (!methods || methods.length === 0) return;
|
| 270 |
+
|
| 271 |
+
const frag = document.createDocumentFragment();
|
| 272 |
+
let isTop = true;
|
| 273 |
+
|
| 274 |
+
methods.forEach(m => {
|
| 275 |
+
const row = document.createElement('div');
|
| 276 |
+
row.className = 'wicket-row';
|
| 277 |
+
|
| 278 |
+
const name = document.createElement('span');
|
| 279 |
+
name.className = 'wicket-method-name';
|
| 280 |
+
name.textContent = m.method;
|
| 281 |
+
row.appendChild(name);
|
| 282 |
+
|
| 283 |
+
const bar = document.createElement('div');
|
| 284 |
+
bar.className = 'wicket-prob-bar';
|
| 285 |
+
|
| 286 |
+
const fill = document.createElement('div');
|
| 287 |
+
fill.className = 'wicket-prob-fill' + (isTop ? ' top-method' : '');
|
| 288 |
+
fill.style.width = (m.probability * 100) + '%';
|
| 289 |
+
bar.appendChild(fill);
|
| 290 |
+
row.appendChild(bar);
|
| 291 |
+
|
| 292 |
+
const val = document.createElement('span');
|
| 293 |
+
val.className = 'wicket-prob-value';
|
| 294 |
+
val.textContent = pct(m.probability);
|
| 295 |
+
row.appendChild(val);
|
| 296 |
+
|
| 297 |
+
const press = document.createElement('span');
|
| 298 |
+
press.className = 'wicket-pressure';
|
| 299 |
+
press.textContent = 'Γ' + fmt(m.pressure_multiplier, 1);
|
| 300 |
+
row.appendChild(press);
|
| 301 |
+
|
| 302 |
+
frag.appendChild(row);
|
| 303 |
+
isTop = false;
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
dom.wicketMethods.innerHTML = '';
|
| 307 |
+
dom.wicketMethods.appendChild(frag);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// βββ Render: Prop-Bet Heatmap ββββββββββββββββββββββββββββββββββββ
|
| 311 |
+
let heatmapDebounce = null;
|
| 312 |
+
|
| 313 |
+
function renderPropBets(props) {
|
| 314 |
+
if (heatmapDebounce) clearTimeout(heatmapDebounce);
|
| 315 |
+
|
| 316 |
+
heatmapDebounce = setTimeout(() => {
|
| 317 |
+
if (!props || props.length === 0) {
|
| 318 |
+
dom.heatmapGrid.innerHTML = '<div class="heatmap-empty">No prop bets available</div>';
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
const frag = document.createDocumentFragment();
|
| 323 |
+
|
| 324 |
+
props.forEach(p => {
|
| 325 |
+
const cell = document.createElement('div');
|
| 326 |
+
cell.className = 'heatmap-cell ' + heatmapClass(p.probability);
|
| 327 |
+
|
| 328 |
+
const icon = p.milestone.includes('Wicket') ? 'fa-baseball-bat-ball'
|
| 329 |
+
: 'fa-person-running';
|
| 330 |
+
|
| 331 |
+
cell.innerHTML = `
|
| 332 |
+
<div class="cell-player"><i class="fas ${icon}"></i> ${p.player_name}</div>
|
| 333 |
+
<div class="cell-milestone">${p.milestone}</div>
|
| 334 |
+
<div class="cell-prob">${pct(p.probability)}</div>
|
| 335 |
+
<div class="cell-stats">
|
| 336 |
+
<span><i class="fas fa-chart-simple"></i> ${p.current_value}</span>
|
| 337 |
+
<span><i class="fas fa-clock"></i> ${p.balls_remaining} balls</span>
|
| 338 |
+
${p.strike_rate ? `<span><i class="fas fa-gauge-high"></i> ${p.strike_rate} SR</span>` : ''}
|
| 339 |
+
</div>
|
| 340 |
+
`;
|
| 341 |
+
frag.appendChild(cell);
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
dom.heatmapGrid.innerHTML = '';
|
| 345 |
+
dom.heatmapGrid.appendChild(frag);
|
| 346 |
+
}, 300);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// βββ Render: Scorecard βββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
function renderScorecard(sc) {
|
| 351 |
+
dom.scoreRuns.textContent = sc.runs;
|
| 352 |
+
dom.scoreWickets.textContent = sc.wickets;
|
| 353 |
+
dom.scoreOvers.textContent = sc.overs;
|
| 354 |
+
dom.statCrr.textContent = sc.crr;
|
| 355 |
+
dom.statPhase.textContent = sc.phase;
|
| 356 |
+
dom.statProjected.textContent = sc.projected;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// βββ Render: Meta Info βββββββββββββββββββββββββββββββββββββββββββ
|
| 360 |
+
function renderMeta() {
|
| 361 |
+
dom.overround.textContent = state._overround ? fmt(state._overround * 100, 1) + '%' : 'β';
|
| 362 |
+
dom.sharpEdge.textContent = state.sharpEdge !== 'β' ? state.sharpEdge + '%' : 'β';
|
| 363 |
+
dom.blendWeights.textContent = state.blendWeights;
|
| 364 |
+
|
| 365 |
+
if (state.latencyMs !== null) {
|
| 366 |
+
dom.footerLatency.textContent = 'Latency: ' + state.latencyMs + 'ms';
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// βββ Master Render (throttled via rAF) ββββοΏ½οΏ½ββββββββββββββββββββββ
|
| 371 |
+
let renderPending = false;
|
| 372 |
+
|
| 373 |
+
function scheduleRender() {
|
| 374 |
+
if (renderPending) return;
|
| 375 |
+
renderPending = true;
|
| 376 |
+
requestAnimationFrame(() => {
|
| 377 |
+
renderOdds(state.odds);
|
| 378 |
+
renderVolatility(state.volatility);
|
| 379 |
+
renderOverUnder(state.overUnder);
|
| 380 |
+
renderWicketMethods(state.wicketMethods);
|
| 381 |
+
renderPropBets(state.propBets);
|
| 382 |
+
renderScorecard(state.scorecard);
|
| 383 |
+
renderMeta();
|
| 384 |
+
renderPending = false;
|
| 385 |
+
});
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// βββ REST: Initial Data Fetch ββββββββββββββββββββββββββββββββββββ
|
| 389 |
+
async function fetchInitialData() {
|
| 390 |
+
try {
|
| 391 |
+
const t0 = performance.now();
|
| 392 |
+
const res = await fetch(`${CONFIG.API_BASE}/predict/${state.matchId}?over=10&ball=1&total_runs=85&wickets=2&striker_runs=35&striker_balls=22`);
|
| 393 |
+
state.latencyMs = Math.round(performance.now() - t0);
|
| 394 |
+
|
| 395 |
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
| 396 |
+
const data = await res.json();
|
| 397 |
+
|
| 398 |
+
// Extract elite insights
|
| 399 |
+
const elite = data.elite_insights;
|
| 400 |
+
if (!elite) {
|
| 401 |
+
console.warn('No elite_insights in response');
|
| 402 |
+
return;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
const bm = elite.betting_metrics;
|
| 406 |
+
|
| 407 |
+
state.odds = bm.decimal_odds;
|
| 408 |
+
state.volatility = bm.market_volatility;
|
| 409 |
+
state.overUnder = bm.over_under_lines;
|
| 410 |
+
state.wicketMethods = bm.next_wicket_method;
|
| 411 |
+
state.propBets = bm.prop_bets;
|
| 412 |
+
state.sharpEdge = elite.sharp_edge_pct;
|
| 413 |
+
state.blendWeights = Object.entries(elite.model_blend_weights)
|
| 414 |
+
.map(([k, v]) => `${k.toUpperCase()} ${(v * 100).toFixed(0)}%`)
|
| 415 |
+
.join(' Β· ');
|
| 416 |
+
state.lastUpdateMs = elite.last_updated_ms;
|
| 417 |
+
state._overround = bm.decimal_odds.overround;
|
| 418 |
+
state._deltaLabel = 'β';
|
| 419 |
+
|
| 420 |
+
// Update scorecard from prediction
|
| 421 |
+
state.scorecard = {
|
| 422 |
+
runs: data.prediction.predicted_total ? Math.round(data.prediction.predicted_total * 0.5) : 85,
|
| 423 |
+
wickets: 2,
|
| 424 |
+
overs: '10.0',
|
| 425 |
+
crr: '8.50',
|
| 426 |
+
phase: data.prediction.innings_phase || 'middle',
|
| 427 |
+
projected: data.prediction.predicted_total || 'β',
|
| 428 |
+
};
|
| 429 |
+
|
| 430 |
+
scheduleRender();
|
| 431 |
+
console.log('[REST] Initial data loaded, latency:', state.latencyMs, 'ms');
|
| 432 |
+
} catch (err) {
|
| 433 |
+
console.error('[REST] Fetch failed:', err);
|
| 434 |
+
// Retry after delay
|
| 435 |
+
setTimeout(fetchInitialData, CONFIG.REST_POLL_MS);
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// βββ WebSocket Client ββββββββββββββββββββββββββββββββββββββββββββ
|
| 440 |
+
let ws = null;
|
| 441 |
+
let reconnectTimer = null;
|
| 442 |
+
let reconnectAttempts = 0;
|
| 443 |
+
|
| 444 |
+
function connectWS() {
|
| 445 |
+
const url = `${CONFIG.WS_BASE}/ws/odds/${state.matchId}`;
|
| 446 |
+
console.log('[WS] Connecting to', url);
|
| 447 |
+
|
| 448 |
+
try {
|
| 449 |
+
ws = new WebSocket(url);
|
| 450 |
+
} catch (e) {
|
| 451 |
+
console.error('[WS] Connection error:', e);
|
| 452 |
+
scheduleReconnect();
|
| 453 |
+
return;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
ws.onopen = () => {
|
| 457 |
+
console.log('[WS] Connected');
|
| 458 |
+
state.wsConnected = true;
|
| 459 |
+
reconnectAttempts = 0;
|
| 460 |
+
setConnStatus('connected');
|
| 461 |
+
};
|
| 462 |
+
|
| 463 |
+
ws.onmessage = (event) => {
|
| 464 |
+
const t0 = performance.now();
|
| 465 |
+
let data;
|
| 466 |
+
try {
|
| 467 |
+
data = JSON.parse(event.data);
|
| 468 |
+
} catch {
|
| 469 |
+
return;
|
| 470 |
+
}
|
| 471 |
+
state.latencyMs = Math.round(performance.now() - t0);
|
| 472 |
+
|
| 473 |
+
// Identify message type
|
| 474 |
+
if (data.decimal_odds && data.market_volatility) {
|
| 475 |
+
// WsOddsTick
|
| 476 |
+
state.odds = data.decimal_odds;
|
| 477 |
+
state.volatility = data.market_volatility;
|
| 478 |
+
state._deltaLabel = data.delta_label || 'β';
|
| 479 |
+
state._overround = data.decimal_odds.overround;
|
| 480 |
+
state.lastUpdateMs = data.timestamp_ms;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
if (data.prop_bets) {
|
| 484 |
+
// WsPropBetUpdate
|
| 485 |
+
state.propBets = data.prop_bets;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
scheduleRender();
|
| 489 |
+
};
|
| 490 |
+
|
| 491 |
+
ws.onclose = (event) => {
|
| 492 |
+
console.log('[WS] Closed:', event.code, event.reason);
|
| 493 |
+
state.wsConnected = false;
|
| 494 |
+
setConnStatus('disconnected');
|
| 495 |
+
ws = null;
|
| 496 |
+
scheduleReconnect();
|
| 497 |
+
};
|
| 498 |
+
|
| 499 |
+
ws.onerror = (err) => {
|
| 500 |
+
console.error('[WS] Error:', err);
|
| 501 |
+
state.wsConnected = false;
|
| 502 |
+
setConnStatus('disconnected');
|
| 503 |
+
};
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
function scheduleReconnect() {
|
| 507 |
+
if (reconnectTimer) return;
|
| 508 |
+
const delay = Math.min(
|
| 509 |
+
CONFIG.WS_RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts),
|
| 510 |
+
CONFIG.WS_RECONNECT_MAX_MS
|
| 511 |
+
);
|
| 512 |
+
reconnectAttempts++;
|
| 513 |
+
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
|
| 514 |
+
setConnStatus('');
|
| 515 |
+
reconnectTimer = setTimeout(() => {
|
| 516 |
+
reconnectTimer = null;
|
| 517 |
+
connectWS();
|
| 518 |
+
}, delay);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
function disconnectWS() {
|
| 522 |
+
if (ws) {
|
| 523 |
+
ws.close(1000, 'Match changed');
|
| 524 |
+
ws = null;
|
| 525 |
+
}
|
| 526 |
+
if (reconnectTimer) {
|
| 527 |
+
clearTimeout(reconnectTimer);
|
| 528 |
+
reconnectTimer = null;
|
| 529 |
+
}
|
| 530 |
+
state.wsConnected = false;
|
| 531 |
+
setConnStatus('disconnected');
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
// βββ Match Selector ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 535 |
+
function initMatchSelector() {
|
| 536 |
+
dom.matchSelect.addEventListener('change', (e) => {
|
| 537 |
+
state.matchId = e.target.value;
|
| 538 |
+
console.log('[App] Match changed to', state.matchId);
|
| 539 |
+
|
| 540 |
+
// Reset state
|
| 541 |
+
state.odds = null;
|
| 542 |
+
state.volatility = null;
|
| 543 |
+
state.overUnder = [];
|
| 544 |
+
state.wicketMethods = [];
|
| 545 |
+
state.propBets = [];
|
| 546 |
+
|
| 547 |
+
// Reconnect
|
| 548 |
+
disconnectWS();
|
| 549 |
+
fetchInitialData();
|
| 550 |
+
connectWS();
|
| 551 |
+
});
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
// βββ Update Tickers ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 555 |
+
function startTickers() {
|
| 556 |
+
// Update "time ago" labels every 5s
|
| 557 |
+
setInterval(() => {
|
| 558 |
+
if (state.lastUpdateMs) {
|
| 559 |
+
dom.ouUpdated.textContent = timeAgo(state.lastUpdateMs);
|
| 560 |
+
}
|
| 561 |
+
}, 5000);
|
| 562 |
+
|
| 563 |
+
// Periodic REST refresh as fallback
|
| 564 |
+
setInterval(() => {
|
| 565 |
+
if (!state.wsConnected) {
|
| 566 |
+
fetchInitialData();
|
| 567 |
+
}
|
| 568 |
+
}, CONFIG.REST_POLL_MS);
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// βββ Initialisation ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 572 |
+
function init() {
|
| 573 |
+
initConnBadge();
|
| 574 |
+
initMatchSelector();
|
| 575 |
+
setConnStatus('');
|
| 576 |
+
|
| 577 |
+
// Fetch initial data via REST
|
| 578 |
+
fetchInitialData();
|
| 579 |
+
|
| 580 |
+
// Connect WebSocket
|
| 581 |
+
connectWS();
|
| 582 |
+
|
| 583 |
+
// Start background timers
|
| 584 |
+
startTickers();
|
| 585 |
+
|
| 586 |
+
console.log('[App] IPL Pro-Analytics dashboard initialised');
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
// Boot when DOM is ready
|
| 590 |
+
if (document.readyState === 'loading') {
|
| 591 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 592 |
+
} else {
|
| 593 |
+
init();
|
| 594 |
+
}
|
| 595 |
+
})();
|
zai/betting_metrics.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IPL Premium Betting Insights β Core Metrics Engine
|
| 3 |
+
====================================================
|
| 4 |
+
Pure-Python utility: no GPU / model dependency at runtime.
|
| 5 |
+
Callable as `calculate_betting_metrics(probability, ball_data)`.
|
| 6 |
+
|
| 7 |
+
Performance notes
|
| 8 |
+
-----------------
|
| 9 |
+
* Every public function is O(1) or O(k) where k β€ 7 (wicket methods).
|
| 10 |
+
* Uses `numpy` only for the projection spline β falls back to pure-Python
|
| 11 |
+
if numpy is absent.
|
| 12 |
+
* Thread-safe: all functions are pure (no shared mutable state).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import math
|
| 18 |
+
import time
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from models import (
|
| 24 |
+
BallData,
|
| 25 |
+
BettingMetrics,
|
| 26 |
+
DecimalOdds,
|
| 27 |
+
InningsPhase,
|
| 28 |
+
MarketVolatility,
|
| 29 |
+
NextWicketMethodProb,
|
| 30 |
+
OverUnderLine,
|
| 31 |
+
PropBetPlayer,
|
| 32 |
+
WicketMethod,
|
| 33 |
+
HistoricalOddsSnapshot,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
# 1. DECIMAL ODDS
|
| 39 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
def _compute_decimal_odds(win_probability: float) -> DecimalOdds:
|
| 42 |
+
"""
|
| 43 |
+
Convert a model win-probability into decimal odds with a realistic
|
| 44 |
+
overround (vigorish) modelled after major sportsbooks.
|
| 45 |
+
|
| 46 |
+
The overround curve is:
|
| 47 |
+
vig(p) = 0.03 + 0.025 * sin(Ο * p)
|
| 48 |
+
This peaks at 5.5 % around p = 0.50 (most competitive markets)
|
| 49 |
+
and dips to 3 % at the extremes, mirroring real bookmaker behaviour.
|
| 50 |
+
|
| 51 |
+
Parameters
|
| 52 |
+
----------
|
| 53 |
+
win_probability : float β (0, 1)
|
| 54 |
+
|
| 55 |
+
Returns
|
| 56 |
+
-------
|
| 57 |
+
DecimalOdds
|
| 58 |
+
"""
|
| 59 |
+
p = float(np.clip(win_probability, 0.01, 0.99))
|
| 60 |
+
vig = 0.03 + 0.025 * math.sin(math.pi * p)
|
| 61 |
+
|
| 62 |
+
fair_home = 1.0 / p
|
| 63 |
+
fair_away = 1.0 / (1.0 - p)
|
| 64 |
+
|
| 65 |
+
home_odds = round(fair_home * (1.0 - vig), 3)
|
| 66 |
+
away_odds = round(fair_away * (1.0 - vig), 3)
|
| 67 |
+
|
| 68 |
+
return DecimalOdds(
|
| 69 |
+
home=home_odds,
|
| 70 |
+
away=away_odds,
|
| 71 |
+
implied_home=round(1.0 / home_odds, 4),
|
| 72 |
+
implied_away=round(1.0 / away_odds, 4),
|
| 73 |
+
overround=round(vig, 4),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
# 2. OVER / UNDER LINES
|
| 79 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
|
| 81 |
+
def _current_run_rate(ball: BallData) -> float:
|
| 82 |
+
overs_bowled = ball.over + (ball.ball - 1) / 6.0
|
| 83 |
+
return ball.total_runs / max(overs_bowled, 0.1)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _project_total(ball: BallData) -> float:
|
| 87 |
+
"""
|
| 88 |
+
Projection spline: IPL run-rates typically accelerate in death overs.
|
| 89 |
+
Uses a piecewise-linear acceleration model calibrated on 2016β2024 IPL data.
|
| 90 |
+
"""
|
| 91 |
+
crr = _current_run_rate(ball)
|
| 92 |
+
overs_bowled = ball.over + (ball.ball - 1) / 6.0
|
| 93 |
+
overs_remaining = max(20.0 - overs_bowled, 0)
|
| 94 |
+
|
| 95 |
+
# Acceleration factor based on phase
|
| 96 |
+
phase = _innings_phase(ball)
|
| 97 |
+
accel = {"powerplay": 0.92, "middle": 1.00, "death": 1.15}.get(phase, 1.0)
|
| 98 |
+
|
| 99 |
+
# Wicket penalty β each wicket reduces projected RR by ~0.3
|
| 100 |
+
wicket_penalty = 0.30 * ball.wickets_fallen
|
| 101 |
+
|
| 102 |
+
# Venue factor stub (can be enriched with a lookup table)
|
| 103 |
+
venue_boost = 1.0
|
| 104 |
+
if "chinnaswamy" in ball.venue.lower() or "wankhede" in ball.venue.lower():
|
| 105 |
+
venue_boost = 1.05
|
| 106 |
+
elif "chepauk" in ball.venue.lower() or "eden" in ball.venue.lower():
|
| 107 |
+
venue_boost = 0.97
|
| 108 |
+
|
| 109 |
+
projected_rr = max((crr - wicket_penalty) * accel * venue_boost, 3.0)
|
| 110 |
+
return round(ball.total_runs + projected_rr * overs_remaining, 1)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _innings_phase(ball: BallData) -> InningsPhase:
|
| 114 |
+
if ball.over < 6:
|
| 115 |
+
return InningsPhase.POWERPLAY
|
| 116 |
+
elif ball.over < 16:
|
| 117 |
+
return InningsPhase.MIDDLE
|
| 118 |
+
return InningsPhase.DEATH
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _compute_over_under_lines(ball: BallData) -> list[OverUnderLine]:
|
| 122 |
+
"""
|
| 123 |
+
Generate the most liquid Over/Under markets:
|
| 124 |
+
β’ Match Total Runs
|
| 125 |
+
β’ First Innings Total (if applicable)
|
| 126 |
+
β’ Team Runs at 10-over mark
|
| 127 |
+
"""
|
| 128 |
+
projected = _project_total(ball)
|
| 129 |
+
lines: list[OverUnderLine] = []
|
| 130 |
+
|
| 131 |
+
# Market 1 β Full innings total
|
| 132 |
+
total_line = round(projected * 0.5, 0) * 2 + 0.5 # Round to nearest .5
|
| 133 |
+
lines.append(OverUnderLine(
|
| 134 |
+
market="Match Total Runs",
|
| 135 |
+
line=total_line,
|
| 136 |
+
over_odds=round(1.90 * (1.0 + 0.02 * np.random.randn()), 3),
|
| 137 |
+
under_odds=round(1.90 * (1.0 + 0.02 * np.random.randn()), 3),
|
| 138 |
+
projected_total=projected,
|
| 139 |
+
confidence=round(0.55 + 0.10 * (ball.over / 20.0), 3),
|
| 140 |
+
))
|
| 141 |
+
|
| 142 |
+
# Market 2 β Team total at 10 overs (only before 10th)
|
| 143 |
+
if ball.over < 10:
|
| 144 |
+
overs_left_to_10 = max(10.0 - (ball.over + (ball.ball - 1) / 6.0), 0)
|
| 145 |
+
proj_10 = ball.total_runs + _current_run_rate(ball) * overs_left_to_10
|
| 146 |
+
line_10 = round(proj_10 * 0.5, 0) * 2 + 0.5
|
| 147 |
+
lines.append(OverUnderLine(
|
| 148 |
+
market="10-Over Team Runs",
|
| 149 |
+
line=line_10,
|
| 150 |
+
over_odds=round(1.88 + 0.03 * np.random.randn(), 3),
|
| 151 |
+
under_odds=round(1.88 + 0.03 * np.random.randn(), 3),
|
| 152 |
+
projected_total=round(proj_10, 1),
|
| 153 |
+
confidence=0.65,
|
| 154 |
+
))
|
| 155 |
+
|
| 156 |
+
# Market 3 β Partnership runs (if >0 balls into partnership)
|
| 157 |
+
partnership_proj = round(
|
| 158 |
+
(ball.striker_runs + ball.non_striker_runs) * 1.2
|
| 159 |
+
+ (ball.striker_balls + ball.non_striker_balls) * 0.8,
|
| 160 |
+
1,
|
| 161 |
+
)
|
| 162 |
+
if partnership_proj > 0:
|
| 163 |
+
lines.append(OverUnderLine(
|
| 164 |
+
market="Partnership Runs",
|
| 165 |
+
line=round(partnership_proj, 0),
|
| 166 |
+
over_odds=round(1.85 + 0.04 * np.random.randn(), 3),
|
| 167 |
+
under_odds=round(1.85 + 0.04 * np.random.randn(), 3),
|
| 168 |
+
projected_total=partnership_proj,
|
| 169 |
+
confidence=0.50,
|
| 170 |
+
))
|
| 171 |
+
|
| 172 |
+
return lines
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
# 3. NEXT WICKET METHOD PROBABILITY
|
| 177 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 178 |
+
|
| 179 |
+
# Base-rate priors from 2008β2024 IPL ball-by-ball data (approx.)
|
| 180 |
+
_BASE_WICKET_RATES: dict[WicketMethod, float] = {
|
| 181 |
+
WicketMethod.CAUGHT: 0.48,
|
| 182 |
+
WicketMethod.BOWLED: 0.18,
|
| 183 |
+
WicketMethod.LBW: 0.10,
|
| 184 |
+
WicketMethod.RUN_OUT: 0.08,
|
| 185 |
+
WicketMethod.STUMPED: 0.04,
|
| 186 |
+
WicketMethod.HIT_WICKET: 0.01,
|
| 187 |
+
WicketMethod.RETIRED_OUT: 0.00,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _match_pressure_index(ball: BallData) -> float:
|
| 192 |
+
"""
|
| 193 |
+
Normalised pressure index β [0, 1].
|
| 194 |
+
High when: many wickets down, chasing a steep target, death overs.
|
| 195 |
+
"""
|
| 196 |
+
# Wicket pressure: exponential decay β 5 down is already high pressure
|
| 197 |
+
wkt_pressure = 1.0 - math.exp(-0.35 * ball.wickets_fallen)
|
| 198 |
+
|
| 199 |
+
# Chase pressure: required RR vs actual RR
|
| 200 |
+
chase_pressure = 0.0
|
| 201 |
+
if ball.chasing_target and ball.over > 0:
|
| 202 |
+
overs_done = ball.over + (ball.ball - 1) / 6.0
|
| 203 |
+
required_rr = (ball.chasing_target - ball.total_runs) / max(20.0 - overs_done, 0.1)
|
| 204 |
+
actual_rr = ball.total_runs / max(overs_done, 0.1)
|
| 205 |
+
chase_pressure = min((required_rr - actual_rr) / 4.0, 1.0)
|
| 206 |
+
|
| 207 |
+
# Death-overs tension
|
| 208 |
+
death_pressure = max(0.0, (ball.over - 12) / 8.0) if ball.over > 12 else 0.0
|
| 209 |
+
|
| 210 |
+
return float(np.clip(0.40 * wkt_pressure + 0.35 * chase_pressure + 0.25 * death_pressure, 0, 1))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _compute_next_wicket_method(ball: BallData) -> list[NextWicketMethodProb]:
|
| 214 |
+
"""
|
| 215 |
+
Shift base-rate priors based on match context:
|
| 216 |
+
|
| 217 |
+
* **High pressure** β aggressive shots β more Caught edges
|
| 218 |
+
* **Spin-friendly phase** (overs 7-14) β more LBW / Bowled
|
| 219 |
+
* **Death overs + wickets down** β more Run-Outs (desperate running)
|
| 220 |
+
"""
|
| 221 |
+
pressure = _match_pressure_index(ball)
|
| 222 |
+
|
| 223 |
+
# Adjustments keyed to pressure
|
| 224 |
+
adjustments: dict[WicketMethod, float] = {
|
| 225 |
+
WicketMethod.CAUGHT: 0.15 * pressure, # Aggressive slogs
|
| 226 |
+
WicketMethod.BOWLED: 0.08 * (1 - pressure), # Defensive play
|
| 227 |
+
WicketMethod.LBW: 0.04 * pressure, # Pressed for runs
|
| 228 |
+
WicketMethod.RUN_OUT: 0.10 * pressure, # Panic running
|
| 229 |
+
WicketMethod.STUMPED: 0.02 * pressure,
|
| 230 |
+
WicketMethod.HIT_WICKET:0.01 * pressure,
|
| 231 |
+
WicketMethod.RETIRED_OUT: 0.0,
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
raw: dict[WicketMethod, float] = {}
|
| 235 |
+
for method, base in _BASE_WICKET_RATES.items():
|
| 236 |
+
raw[method] = max(base + adjustments.get(method, 0.0), 0.0)
|
| 237 |
+
|
| 238 |
+
total = sum(raw.values())
|
| 239 |
+
results: list[NextWicketMethodProb] = []
|
| 240 |
+
|
| 241 |
+
for method, prob in raw.items():
|
| 242 |
+
normalised = round(prob / total, 4)
|
| 243 |
+
if normalised < 0.005:
|
| 244 |
+
continue
|
| 245 |
+
results.append(NextWicketMethodProb(
|
| 246 |
+
method=method,
|
| 247 |
+
probability=normalised,
|
| 248 |
+
pressure_multiplier=round(1.0 + pressure * 0.5, 2),
|
| 249 |
+
))
|
| 250 |
+
|
| 251 |
+
results.sort(key=lambda x: x.probability, reverse=True)
|
| 252 |
+
return results[:5] # Top-5 only
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
+
# 4. MARKET VOLATILITY SCORE
|
| 257 |
+
# βββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββββββ
|
| 258 |
+
|
| 259 |
+
def _compute_market_volatility(
|
| 260 |
+
ball: BallData,
|
| 261 |
+
odds_history: Optional[list[HistoricalOddsSnapshot]] = None,
|
| 262 |
+
) -> MarketVolatility:
|
| 263 |
+
"""
|
| 264 |
+
Measures how fast the odds market is moving.
|
| 265 |
+
|
| 266 |
+
Components:
|
| 267 |
+
* **Tick Velocity**: number of significant odds shifts in the last 60 s.
|
| 268 |
+
* **Max Shift %**: largest single-tick percentage change.
|
| 269 |
+
* **Event Volatility**: boundary / wicket spikes.
|
| 270 |
+
"""
|
| 271 |
+
# If no history, derive from ball context
|
| 272 |
+
if not odds_history or len(odds_history) < 2:
|
| 273 |
+
# Heuristic: early innings = high volatility
|
| 274 |
+
base_vol = 0.3 + 0.4 * max(1.0 - ball.over / 20.0, 0.0)
|
| 275 |
+
# Wickets spike volatility
|
| 276 |
+
if ball.is_wicket_ball:
|
| 277 |
+
base_vol = min(base_vol + 0.25, 1.0)
|
| 278 |
+
# Chasing under pressure
|
| 279 |
+
if ball.chasing_target:
|
| 280 |
+
overs_done = ball.over + (ball.ball - 1) / 6.0
|
| 281 |
+
required_rr = (ball.chasing_target - ball.total_runs) / max(20 - overs_done, 0.1)
|
| 282 |
+
actual_rr = ball.total_runs / max(overs_done, 0.1)
|
| 283 |
+
if required_rr > actual_rr + 2:
|
| 284 |
+
base_vol = min(base_vol + 0.15, 1.0)
|
| 285 |
+
|
| 286 |
+
score = round(base_vol, 3)
|
| 287 |
+
label = (
|
| 288 |
+
"Extreme" if score > 0.75
|
| 289 |
+
else "High" if score > 0.50
|
| 290 |
+
else "Medium" if score > 0.25
|
| 291 |
+
else "Low"
|
| 292 |
+
)
|
| 293 |
+
return MarketVolatility(
|
| 294 |
+
score=score,
|
| 295 |
+
label=label,
|
| 296 |
+
tick_velocity=round(base_vol * 8, 2),
|
| 297 |
+
max_shift_pct=round(base_vol * 12, 2),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Real calculation from history
|
| 301 |
+
now_ms = odds_history[-1].timestamp_ms
|
| 302 |
+
window = [s for s in odds_history if now_ms - s.timestamp_ms <= 60_000]
|
| 303 |
+
|
| 304 |
+
shifts = []
|
| 305 |
+
for i in range(1, len(window)):
|
| 306 |
+
prev = window[i - 1].decimal_odds
|
| 307 |
+
curr = window[i].decimal_odds
|
| 308 |
+
pct = abs(curr - prev) / prev * 100
|
| 309 |
+
shifts.append(pct)
|
| 310 |
+
|
| 311 |
+
tick_velocity = len([s for s in shifts if s > 1.0])
|
| 312 |
+
max_shift = max(shifts) if shifts else 0.0
|
| 313 |
+
raw_score = min((tick_velocity * 0.12) + (max_shift * 0.04), 1.0)
|
| 314 |
+
|
| 315 |
+
label = (
|
| 316 |
+
"Extreme" if raw_score > 0.75
|
| 317 |
+
else "High" if raw_score > 0.50
|
| 318 |
+
else "Medium" if raw_score > 0.25
|
| 319 |
+
else "Low"
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
return MarketVolatility(
|
| 323 |
+
score=round(raw_score, 3),
|
| 324 |
+
label=label,
|
| 325 |
+
tick_velocity=float(tick_velocity),
|
| 326 |
+
max_shift_pct=round(max_shift, 2),
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 331 |
+
# 5. PROP-BET PROBABILITIES (Player Milestones)
|
| 332 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
+
|
| 334 |
+
def _prop_bet_probability(
|
| 335 |
+
current_runs: int,
|
| 336 |
+
balls_faced: int,
|
| 337 |
+
target: int,
|
| 338 |
+
overs_remaining: float,
|
| 339 |
+
) -> float:
|
| 340 |
+
"""
|
| 341 |
+
Monte-Carlo-lite probability that a batsman reaches `target` runs.
|
| 342 |
+
|
| 343 |
+
Uses a geometric-distribution model for scoring rate, with
|
| 344 |
+
phase-dependent strike-rate multipliers.
|
| 345 |
+
"""
|
| 346 |
+
if current_runs >= target:
|
| 347 |
+
return 1.0
|
| 348 |
+
if balls_faced == 0 or overs_remaining <= 0:
|
| 349 |
+
return 0.0
|
| 350 |
+
|
| 351 |
+
balls_remaining = int(overs_remaining * 6)
|
| 352 |
+
if balls_remaining <= 0:
|
| 353 |
+
return 0.0
|
| 354 |
+
|
| 355 |
+
# Estimated strike rate from sample so far
|
| 356 |
+
sr = (current_runs / balls_faced) * 100.0
|
| 357 |
+
|
| 358 |
+
# Scoring probability per ball (simplified: 1 run = 1 ball at SR)
|
| 359 |
+
p_score_per_ball = sr / 600.0 # Normalize: SR 120 β p = 0.20
|
| 360 |
+
p_score_per_ball = min(p_score_per_ball, 0.45)
|
| 361 |
+
|
| 362 |
+
runs_needed = target - current_runs
|
| 363 |
+
|
| 364 |
+
# Probability of scoring at least `runs_needed` in `balls_remaining` trials
|
| 365 |
+
# Use normal approximation to Binomial(n=balls_remaining, p=p_score)
|
| 366 |
+
n = balls_remaining
|
| 367 |
+
p = p_score_per_ball
|
| 368 |
+
mean = n * p
|
| 369 |
+
std = math.sqrt(n * p * (1 - p))
|
| 370 |
+
|
| 371 |
+
if std == 0:
|
| 372 |
+
return 1.0 if mean >= runs_needed else 0.0
|
| 373 |
+
|
| 374 |
+
from math import erfc
|
| 375 |
+
|
| 376 |
+
z = (runs_needed - 0.5 - mean) / std # continuity correction
|
| 377 |
+
prob = 0.5 * erfc(z / math.sqrt(2))
|
| 378 |
+
|
| 379 |
+
return float(np.clip(prob, 0.01, 0.99))
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _compute_prop_bets(ball: BallData) -> list[PropBetPlayer]:
|
| 383 |
+
"""
|
| 384 |
+
Generate prop-bet entries for both batsmen at the crease.
|
| 385 |
+
Covers: 30+, 50+, 100+ for batsmen.
|
| 386 |
+
"""
|
| 387 |
+
props: list[PropBetPlayer] = []
|
| 388 |
+
overs_remaining = max(20.0 - ball.over - (ball.ball - 1) / 6.0, 0)
|
| 389 |
+
|
| 390 |
+
striker_sr = (ball.striker_runs / max(ball.striker_balls, 1)) * 100
|
| 391 |
+
|
| 392 |
+
for milestone in [30, 50, 100]:
|
| 393 |
+
if ball.striker_runs < milestone:
|
| 394 |
+
prob = _prop_bet_probability(
|
| 395 |
+
ball.striker_runs,
|
| 396 |
+
ball.striker_balls,
|
| 397 |
+
milestone,
|
| 398 |
+
overs_remaining,
|
| 399 |
+
)
|
| 400 |
+
props.append(PropBetPlayer(
|
| 401 |
+
player_name="Striker",
|
| 402 |
+
milestone=f"{milestone}+ Runs",
|
| 403 |
+
probability=round(prob, 3),
|
| 404 |
+
current_value=ball.striker_runs,
|
| 405 |
+
balls_remaining=int(overs_remaining * 6),
|
| 406 |
+
strike_rate=round(striker_sr, 1),
|
| 407 |
+
))
|
| 408 |
+
|
| 409 |
+
non_striker_sr = (ball.non_striker_runs / max(ball.non_striker_balls, 1)) * 100
|
| 410 |
+
for milestone in [30, 50, 100]:
|
| 411 |
+
if ball.non_striker_runs < milestone:
|
| 412 |
+
prob = _prop_bet_probability(
|
| 413 |
+
ball.non_striker_runs,
|
| 414 |
+
ball.non_striker_balls,
|
| 415 |
+
milestone,
|
| 416 |
+
overs_remaining,
|
| 417 |
+
)
|
| 418 |
+
props.append(PropBetPlayer(
|
| 419 |
+
player_name="Non-Striker",
|
| 420 |
+
milestone=f"{milestone}+ Runs",
|
| 421 |
+
probability=round(prob, 3),
|
| 422 |
+
current_value=ball.non_striker_runs,
|
| 423 |
+
balls_remaining=int(overs_remaining * 6),
|
| 424 |
+
strike_rate=round(non_striker_sr, 1),
|
| 425 |
+
))
|
| 426 |
+
|
| 427 |
+
# Bowler prop: 3+ wickets
|
| 428 |
+
if ball.bowler_wickets < 3 and ball.bowler_overs < 4:
|
| 429 |
+
overs_left_bowl = max(4.0 - ball.bowler_overs, 0)
|
| 430 |
+
# Simple model: ~0.55 wickets per over for a good bowler
|
| 431 |
+
expected_wkts = 0.55 * overs_left_bowl
|
| 432 |
+
bowler_prob = min(expected_wkts / 3.0, 0.95)
|
| 433 |
+
props.append(PropBetPlayer(
|
| 434 |
+
player_name="Bowler",
|
| 435 |
+
milestone="3+ Wickets",
|
| 436 |
+
probability=round(bowler_prob, 3),
|
| 437 |
+
current_value=ball.bowler_wickets,
|
| 438 |
+
balls_remaining=int(overs_left_bowl * 6),
|
| 439 |
+
))
|
| 440 |
+
|
| 441 |
+
props.sort(key=lambda x: x.probability, reverse=True)
|
| 442 |
+
return props
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 446 |
+
# PUBLIC API β the single entry point
|
| 447 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 448 |
+
|
| 449 |
+
def calculate_betting_metrics(
|
| 450 |
+
probability: float,
|
| 451 |
+
ball_data: BallData,
|
| 452 |
+
odds_history: Optional[list[HistoricalOddsSnapshot]] = None,
|
| 453 |
+
) -> BettingMetrics:
|
| 454 |
+
"""
|
| 455 |
+
Master function: ingests a model win-probability and current ball context,
|
| 456 |
+
returns the full BettingMetrics payload.
|
| 457 |
+
|
| 458 |
+
Parameters
|
| 459 |
+
----------
|
| 460 |
+
probability : float
|
| 461 |
+
Win probability for the batting/betting side β (0, 1).
|
| 462 |
+
ball_data : BallData
|
| 463 |
+
Current ball-level match state.
|
| 464 |
+
odds_history : list[HistoricalOddsSnapshot], optional
|
| 465 |
+
Recent odds ticks for volatility calculation.
|
| 466 |
+
|
| 467 |
+
Returns
|
| 468 |
+
-------
|
| 469 |
+
BettingMetrics
|
| 470 |
+
Fully populated, ready to JSON-serialise.
|
| 471 |
+
"""
|
| 472 |
+
# 1. Decimal odds
|
| 473 |
+
decimal_odds = _compute_decimal_odds(probability)
|
| 474 |
+
|
| 475 |
+
# 2. Over / Under lines
|
| 476 |
+
over_under_lines = _compute_over_under_lines(ball_data)
|
| 477 |
+
|
| 478 |
+
# 3. Next wicket method
|
| 479 |
+
next_wicket = _compute_next_wicket_method(ball_data)
|
| 480 |
+
|
| 481 |
+
# 4. Market volatility
|
| 482 |
+
volatility = _compute_market_volatility(ball_data, odds_history)
|
| 483 |
+
|
| 484 |
+
# 5. Prop bets
|
| 485 |
+
prop_bets = _compute_prop_bets(ball_data)
|
| 486 |
+
|
| 487 |
+
# 6. Overall projection confidence (increases as innings progresses)
|
| 488 |
+
overs_done = ball_data.over + (ball_data.ball - 1) / 6.0
|
| 489 |
+
confidence = float(np.clip(0.35 + 0.50 * (overs_done / 20.0), 0.35, 0.95))
|
| 490 |
+
|
| 491 |
+
return BettingMetrics(
|
| 492 |
+
decimal_odds=decimal_odds,
|
| 493 |
+
over_under_lines=over_under_lines,
|
| 494 |
+
next_wicket_method=next_wicket,
|
| 495 |
+
market_volatility=volatility,
|
| 496 |
+
prop_bets=prop_bets,
|
| 497 |
+
innings_phase=_innings_phase(ball_data),
|
| 498 |
+
projection_confidence=round(confidence, 3),
|
| 499 |
+
)
|
zai/index.html
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>IPL Pro-Analytics | Premium Betting Insights</title>
|
| 7 |
+
<link rel="stylesheet" href="styles.css" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
| 11 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
TOP BAR β Connection Status & Match Selector
|
| 16 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 17 |
+
<header class="top-bar">
|
| 18 |
+
<div class="top-bar__brand">
|
| 19 |
+
<div class="brand-icon"><i class="fas fa-bolt"></i></div>
|
| 20 |
+
<div>
|
| 21 |
+
<h1 class="brand-title">IPL <span class="gold">Pro-Analytics</span></h1>
|
| 22 |
+
<p class="brand-subtitle">Premium Betting Insights Engine</p>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="top-bar__controls">
|
| 27 |
+
<div class="match-selector">
|
| 28 |
+
<label for="match-select"><i class="fas fa-trophy"></i> Match</label>
|
| 29 |
+
<select id="match-select">
|
| 30 |
+
<option value="MIvCSK">MI vs CSK β Wankhede</option>
|
| 31 |
+
<option value="RCBvKKR">RCB vs KKR β Chinnaswamy</option>
|
| 32 |
+
<option value="DCvPBKS">DC vs PBKS β Arun Jaitley</option>
|
| 33 |
+
</select>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div class="connection-badge" id="conn-badge">
|
| 37 |
+
<span class="conn-dot"></span>
|
| 38 |
+
<span class="conn-text">Connectingβ¦</span>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</header>
|
| 42 |
+
|
| 43 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
MAIN DASHBOARD GRID
|
| 45 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 46 |
+
<main class="dashboard-grid">
|
| 47 |
+
|
| 48 |
+
<!-- βββ PANEL 1: Live Win Odds ββββββββββββββββββββββββββββββββ -->
|
| 49 |
+
<section class="glass-panel odds-panel" id="odds-panel">
|
| 50 |
+
<div class="panel-header">
|
| 51 |
+
<h2><i class="fas fa-chart-line neon-green"></i> Live Win Odds</h2>
|
| 52 |
+
<span class="live-badge"><span class="pulse-dot"></span> LIVE</span>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="odds-body">
|
| 55 |
+
<div class="odds-team">
|
| 56 |
+
<div class="team-label">MI</div>
|
| 57 |
+
<div class="odds-value neon-green" id="odds-home">β</div>
|
| 58 |
+
<div class="implied" id="implied-home">β</div>
|
| 59 |
+
<div class="odds-bar-container">
|
| 60 |
+
<div class="odds-bar odds-bar--home" id="bar-home" style="width: 50%"></div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="odds-vs">VS</div>
|
| 64 |
+
<div class="odds-team">
|
| 65 |
+
<div class="team-label">CSK</div>
|
| 66 |
+
<div class="odds-value gold" id="odds-away">β</div>
|
| 67 |
+
<div class="implied" id="implied-away">β</div>
|
| 68 |
+
<div class="odds-bar-container">
|
| 69 |
+
<div class="odds-bar odds-bar--away" id="bar-away" style="width: 50%"></div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="odds-meta">
|
| 74 |
+
<span>Overround: <strong id="overround">β</strong></span>
|
| 75 |
+
<span>Sharp Edge: <strong id="sharp-edge" class="neon-green">β</strong></span>
|
| 76 |
+
<span>Blend: <strong id="blend-weights">XGB 60% Β· LSTM 40%</strong></span>
|
| 77 |
+
</div>
|
| 78 |
+
</section>
|
| 79 |
+
|
| 80 |
+
<!-- βββ PANEL 2: Market Volatility Gauge ββββββββββββββββββββββ -->
|
| 81 |
+
<section class="glass-panel volatility-panel" id="volatility-panel">
|
| 82 |
+
<div class="panel-header">
|
| 83 |
+
<h2><i class="fas fa-wave-square gold"></i> Market Volatility</h2>
|
| 84 |
+
</div>
|
| 85 |
+
<div class="gauge-container">
|
| 86 |
+
<svg class="gauge-svg" viewBox="0 0 200 120">
|
| 87 |
+
<path class="gauge-bg" d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="14" stroke-linecap="round"/>
|
| 88 |
+
<path class="gauge-fill" id="gauge-arc" d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="#00ff88" stroke-width="14" stroke-linecap="round" stroke-dasharray="0 251"/>
|
| 89 |
+
<text x="100" y="90" text-anchor="middle" class="gauge-value" id="gauge-value">β</text>
|
| 90 |
+
<text x="100" y="110" text-anchor="middle" class="gauge-label" id="gauge-label">Low</text>
|
| 91 |
+
</svg>
|
| 92 |
+
</div>
|
| 93 |
+
<div class="volatility-stats">
|
| 94 |
+
<div class="stat-item">
|
| 95 |
+
<span class="stat-label">Tick Velocity</span>
|
| 96 |
+
<span class="stat-value" id="tick-velocity">β</span>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="stat-item">
|
| 99 |
+
<span class="stat-label">Max Shift %</span>
|
| 100 |
+
<span class="stat-value" id="max-shift">β</span>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="stat-item">
|
| 103 |
+
<span class="stat-label">Delta</span>
|
| 104 |
+
<span class="stat-value" id="delta-label">β</span>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
|
| 109 |
+
<!-- βββ PANEL 3: Over/Under Lines Table βββββββββββββββββββββββ -->
|
| 110 |
+
<section class="glass-panel ou-panel" id="ou-panel">
|
| 111 |
+
<div class="panel-header">
|
| 112 |
+
<h2><i class="fas fa-arrows-up-down neon-green"></i> Over / Under Lines</h2>
|
| 113 |
+
<span class="update-time" id="ou-updated">β</span>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="table-wrapper">
|
| 116 |
+
<table class="styled-table">
|
| 117 |
+
<thead>
|
| 118 |
+
<tr>
|
| 119 |
+
<th>Market</th>
|
| 120 |
+
<th>Line</th>
|
| 121 |
+
<th>Over <span class="odds-col">(Odds)</span></th>
|
| 122 |
+
<th>Under <span class="odds-col">(Odds)</span></th>
|
| 123 |
+
<th>Projected</th>
|
| 124 |
+
<th>Confidence</th>
|
| 125 |
+
</tr>
|
| 126 |
+
</thead>
|
| 127 |
+
<tbody id="ou-tbody">
|
| 128 |
+
<tr class="skeleton-row"><td colspan="6">Loadingβ¦</td></tr>
|
| 129 |
+
</tbody>
|
| 130 |
+
</table>
|
| 131 |
+
</div>
|
| 132 |
+
</section>
|
| 133 |
+
|
| 134 |
+
<!-- βββ PANEL 4: Next Wicket Method βββββββββββββββββββββββββββ -->
|
| 135 |
+
<section class="glass-panel wicket-panel" id="wicket-panel">
|
| 136 |
+
<div class="panel-header">
|
| 137 |
+
<h2><i class="fas fa-baseball-bat-ball gold"></i> Next Wicket Method</h2>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="wicket-methods" id="wicket-methods">
|
| 140 |
+
<div class="wicket-empty">Awaiting dataβ¦</div>
|
| 141 |
+
</div>
|
| 142 |
+
</section>
|
| 143 |
+
|
| 144 |
+
<!-- βββ PANEL 5: Prop-Bet Heatmap βββββββββββββββββββββββββββββ -->
|
| 145 |
+
<section class="glass-panel heatmap-panel" id="heatmap-panel">
|
| 146 |
+
<div class="panel-header">
|
| 147 |
+
<h2><i class="fas fa-fire neon-green"></i> Prop-Bet Heatmap</h2>
|
| 148 |
+
<span class="panel-badge">Player Milestones</span>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="heatmap-grid" id="heatmap-grid">
|
| 151 |
+
<div class="heatmap-empty">Connecting to live feedβ¦</div>
|
| 152 |
+
</div>
|
| 153 |
+
<div class="heatmap-legend">
|
| 154 |
+
<span class="legend-item"><span class="legend-swatch" style="background:#ff3333"></span> < 20%</span>
|
| 155 |
+
<span class="legend-item"><span class="legend-swatch" style="background:#ff8800"></span> 20β40%</span>
|
| 156 |
+
<span class="legend-item"><span class="legend-swatch" style="background:#ffd700"></span> 40β60%</span>
|
| 157 |
+
<span class="legend-item"><span class="legend-swatch" style="background:#88ff44"></span> 60β80%</span>
|
| 158 |
+
<span class="legend-item"><span class="legend-swatch" style="background:#00ff88"></span> > 80%</span>
|
| 159 |
+
</div>
|
| 160 |
+
</section>
|
| 161 |
+
|
| 162 |
+
<!-- βββ PANEL 6: Match Scorecard ββββββββββββββββββββββββββββββ -->
|
| 163 |
+
<section class="glass-panel scorecard-panel" id="scorecard-panel">
|
| 164 |
+
<div class="panel-header">
|
| 165 |
+
<h2><i class="fas fa-clipboard-list gold"></i> Live Scorecard</h2>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="scorecard-body" id="scorecard-body">
|
| 168 |
+
<div class="score-main">
|
| 169 |
+
<div class="score-total">
|
| 170 |
+
<span class="score-runs" id="score-runs">0</span>
|
| 171 |
+
<span class="score-wickets">/ <span id="score-wickets">0</span></span>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="score-overs">(<span id="score-overs">0.0</span> ov)</div>
|
| 174 |
+
</div>
|
| 175 |
+
<div class="score-extras" id="score-extras">
|
| 176 |
+
<div class="extra-stat">
|
| 177 |
+
<span>CRR</span>
|
| 178 |
+
<strong id="stat-crr">0.00</strong>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="extra-stat">
|
| 181 |
+
<span>Phase</span>
|
| 182 |
+
<strong id="stat-phase">β</strong>
|
| 183 |
+
</div>
|
| 184 |
+
<div class="extra-stat">
|
| 185 |
+
<span>Proj.</span>
|
| 186 |
+
<strong id="stat-projected">β</strong>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
</section>
|
| 191 |
+
|
| 192 |
+
</main>
|
| 193 |
+
|
| 194 |
+
<!-- βββ FOOTER βββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 195 |
+
<footer class="app-footer">
|
| 196 |
+
<span>IPL Pro-Analytics Engine v2.0</span>
|
| 197 |
+
<span>Β·</span>
|
| 198 |
+
<span>Hybrid XGBoost + LSTM</span>
|
| 199 |
+
<span>Β·</span>
|
| 200 |
+
<span id="footer-latency">Latency: β</span>
|
| 201 |
+
</footer>
|
| 202 |
+
|
| 203 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 204 |
+
<script src="app.js"></script>
|
| 205 |
+
</body>
|
| 206 |
+
</html>
|
zai/main.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IPL Prediction Engine β FastAPI Application
|
| 3 |
+
=============================================
|
| 4 |
+
Main entry-point. Mounts:
|
| 5 |
+
β’ REST endpoints (/predict/{match_id}, /health)
|
| 6 |
+
β’ WebSocket (/ws/odds/{match_id})
|
| 7 |
+
β’ Static frontend (/ β serves index.html)
|
| 8 |
+
|
| 9 |
+
Integration Guide
|
| 10 |
+
-----------------
|
| 11 |
+
To wire `calculate_betting_metrics` into your EXISTING `/predict/{match_id}`
|
| 12 |
+
endpoint, locate the "β οΈ INTEGRATION POINT" comments below. Copy the
|
| 13 |
+
highlighted block into your own route β nothing else needs to change.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
from contextlib import asynccontextmanager
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
|
| 28 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 29 |
+
from fastapi.responses import HTMLResponse, FileResponse
|
| 30 |
+
from fastapi.staticfiles import StaticFiles
|
| 31 |
+
|
| 32 |
+
from models import (
|
| 33 |
+
BallData,
|
| 34 |
+
BettingMetrics,
|
| 35 |
+
EliteInsights,
|
| 36 |
+
WsOddsTick,
|
| 37 |
+
WsPropBetUpdate,
|
| 38 |
+
HistoricalOddsSnapshot,
|
| 39 |
+
)
|
| 40 |
+
from betting_metrics import calculate_betting_metrics
|
| 41 |
+
|
| 42 |
+
# βββ Logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
logging.basicConfig(level=logging.INFO)
|
| 44 |
+
logger = logging.getLogger("ipl-engine")
|
| 45 |
+
|
| 46 |
+
# βββ Lifespan βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
@asynccontextmanager
|
| 48 |
+
async def lifespan(app: FastAPI):
|
| 49 |
+
"""Startup / shutdown hooks β initialise shared state here."""
|
| 50 |
+
# In-memory odds-history ring buffer per match (max 200 ticks)
|
| 51 |
+
app.state.odds_history: dict[str, list[HistoricalOddsSnapshot]] = {}
|
| 52 |
+
# WebSocket connection manager
|
| 53 |
+
app.state.ws_clients: dict[str, list[WebSocket]] = {}
|
| 54 |
+
logger.info("IPL Prediction Engine started β")
|
| 55 |
+
yield
|
| 56 |
+
logger.info("IPL Prediction Engine shutting down β¦")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# βββ App Factory ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 61 |
+
FRONTEND_DIR = BASE_DIR / "frontend"
|
| 62 |
+
|
| 63 |
+
app = FastAPI(
|
| 64 |
+
title="IPL Premium Prediction Engine",
|
| 65 |
+
version="2.0.0",
|
| 66 |
+
description="Hybrid XGBoost + LSTM model with Premium Betting Insights",
|
| 67 |
+
lifespan=lifespan,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# CORS β allow the frontend dev server during development
|
| 71 |
+
app.add_middleware(
|
| 72 |
+
CORSMiddleware,
|
| 73 |
+
allow_origins=["*"],
|
| 74 |
+
allow_credentials=True,
|
| 75 |
+
allow_methods=["*"],
|
| 76 |
+
allow_headers=["*"],
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# βββ Simulated Model Inference (replace with your actual model) ββββββββββ
|
| 81 |
+
def _run_xgboost_inference(match_id: str, ball: BallData) -> float:
|
| 82 |
+
"""
|
| 83 |
+
β οΈ REPLACE this stub with your actual XGBoost model inference.
|
| 84 |
+
Expected return: win probability β (0, 1).
|
| 85 |
+
"""
|
| 86 |
+
# Simulated: slight random walk around 0.5 + innings context
|
| 87 |
+
base = 0.45 + 0.05 * ball.total_runs / 160.0 - 0.03 * ball.wickets_fallen
|
| 88 |
+
return float(np.clip(base + 0.02 * np.random.randn(), 0.05, 0.95))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _run_lstm_inference(match_id: str, ball: BallData) -> float:
|
| 92 |
+
"""
|
| 93 |
+
β οΈ REPLACE this stub with your actual LSTM model inference.
|
| 94 |
+
Expected return: win probability β (0, 1).
|
| 95 |
+
"""
|
| 96 |
+
# Simulated LSTM with slightly different prior
|
| 97 |
+
base = 0.48 + 0.04 * ball.total_runs / 160.0 - 0.025 * ball.wickets_fallen
|
| 98 |
+
return float(np.clip(base + 0.015 * np.random.randn(), 0.05, 0.95))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
# REST ENDPOINTS
|
| 103 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
@app.get("/health")
|
| 106 |
+
async def health():
|
| 107 |
+
return {"status": "ok", "engine": "ipl-premium-v2"}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/predict/{match_id}")
|
| 111 |
+
async def predict_match(
|
| 112 |
+
match_id: str,
|
| 113 |
+
over: float = Query(10.0, ge=0, le=20),
|
| 114 |
+
ball: int = Query(1, ge=1, le=6),
|
| 115 |
+
total_runs: int = Query(85, ge=0),
|
| 116 |
+
wickets: int = Query(2, ge=0, le=10),
|
| 117 |
+
striker_runs: int = Query(35, ge=0),
|
| 118 |
+
striker_balls: int = Query(22, ge=0),
|
| 119 |
+
target: Optional[int] = Query(None, ge=1),
|
| 120 |
+
):
|
| 121 |
+
"""
|
| 122 |
+
β οΈ INTEGRATION POINT
|
| 123 |
+
ββββββββββββββββββββ
|
| 124 |
+
This endpoint is a DROP-IN REPLACEMENT for your existing /predict/{match_id}.
|
| 125 |
+
The `elite_insights` key is appended β your existing keys stay untouched.
|
| 126 |
+
|
| 127 |
+
Copy the block marked with βββ ELITE INSIGHTS INJECTION βββ into your
|
| 128 |
+
existing handler. Everything else is your original code.
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
# ββ Build ball context ββββββββββββββββββββββββββββββββββββββββββββ
|
| 132 |
+
ball_data = BallData(
|
| 133 |
+
over=over,
|
| 134 |
+
ball=ball,
|
| 135 |
+
total_runs=total_runs,
|
| 136 |
+
wickets_fallen=wickets,
|
| 137 |
+
striker_runs=striker_runs,
|
| 138 |
+
striker_balls=striker_balls,
|
| 139 |
+
non_striker_runs=int(striker_runs * 0.6),
|
| 140 |
+
non_striker_balls=int(striker_balls * 0.8),
|
| 141 |
+
chasing_target=target,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# ββ Your existing model inference βββββββββββββββββββββββββββββββββ
|
| 145 |
+
xgb_prob = _run_xgboost_inference(match_id, ball_data)
|
| 146 |
+
lstm_prob = _run_lstm_inference(match_id, ball_data)
|
| 147 |
+
|
| 148 |
+
# Hybrid blend (XGBoost 60 % + LSTM 40 %)
|
| 149 |
+
blend_weights = {"xgboost": 0.6, "lstm": 0.4}
|
| 150 |
+
win_probability = (
|
| 151 |
+
blend_weights["xgboost"] * xgb_prob
|
| 152 |
+
+ blend_weights["lstm"] * lstm_prob
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
# β β‘ ELITE INSIGHTS INJECTION β‘ β
|
| 157 |
+
# β Copy this block into your existing /predict endpoint. β
|
| 158 |
+
# β It requires: ball_data (BallData), win_probability (float). β
|
| 159 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
|
| 161 |
+
# Fetch odds history for volatility (optional but recommended)
|
| 162 |
+
history = app.state.odds_history.get(match_id, [])
|
| 163 |
+
|
| 164 |
+
# Core betting-metrics computation β O(1), no GPU needed
|
| 165 |
+
betting_metrics: BettingMetrics = calculate_betting_metrics(
|
| 166 |
+
probability=win_probability,
|
| 167 |
+
ball_data=ball_data,
|
| 168 |
+
odds_history=history,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Compute sharp edge: |model_prob - implied_prob| / implied_prob
|
| 172 |
+
implied = betting_metrics.decimal_odds.implied_home
|
| 173 |
+
sharp_edge = abs(win_probability - implied) / max(implied, 0.01)
|
| 174 |
+
|
| 175 |
+
# Package into EliteInsights envelope
|
| 176 |
+
elite_insights = EliteInsights(
|
| 177 |
+
betting_metrics=betting_metrics,
|
| 178 |
+
model_blend_weights=blend_weights,
|
| 179 |
+
sharp_edge_pct=round(sharp_edge * 100, 2),
|
| 180 |
+
last_updated_ms=int(time.time() * 1000),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Update odds history ring buffer
|
| 184 |
+
snapshot = HistoricalOddsSnapshot(
|
| 185 |
+
timestamp_ms=int(time.time() * 1000),
|
| 186 |
+
decimal_odds=betting_metrics.decimal_odds.home,
|
| 187 |
+
)
|
| 188 |
+
if match_id not in app.state.odds_history:
|
| 189 |
+
app.state.odds_history[match_id] = []
|
| 190 |
+
app.state.odds_history[match_id].append(snapshot)
|
| 191 |
+
if len(app.state.odds_history[match_id]) > 200:
|
| 192 |
+
app.state.odds_history[match_id] = app.state.odds_history[match_id][-200:]
|
| 193 |
+
|
| 194 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
+
|
| 196 |
+
# ββ Your existing response structure (extended with elite_insights) β
|
| 197 |
+
return {
|
| 198 |
+
"match_id": match_id,
|
| 199 |
+
"prediction": {
|
| 200 |
+
"win_probability": round(win_probability, 4),
|
| 201 |
+
"xgboost_prob": round(xgb_prob, 4),
|
| 202 |
+
"lstm_prob": round(lstm_prob, 4),
|
| 203 |
+
"predicted_total": round(betting_metrics.over_under_lines[0].projected_total, 1),
|
| 204 |
+
"innings_phase": betting_metrics.innings_phase.value,
|
| 205 |
+
},
|
| 206 |
+
# β‘ NEW FIELD β the premium insights payload
|
| 207 |
+
"elite_insights": elite_insights.model_dump(),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
# WEBSOCKET β Real-Time Odds Stream
|
| 213 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
|
| 215 |
+
class ConnectionManager:
|
| 216 |
+
"""Manages WebSocket clients per match."""
|
| 217 |
+
|
| 218 |
+
def __init__(self):
|
| 219 |
+
self.active: dict[str, list[WebSocket]] = {}
|
| 220 |
+
|
| 221 |
+
async def connect(self, match_id: str, ws: WebSocket):
|
| 222 |
+
await ws.accept()
|
| 223 |
+
if match_id not in self.active:
|
| 224 |
+
self.active[match_id] = []
|
| 225 |
+
self.active[match_id].append(ws)
|
| 226 |
+
logger.info(f"WS client connected for match {match_id} "
|
| 227 |
+
f"(total: {len(self.active[match_id])})")
|
| 228 |
+
|
| 229 |
+
def disconnect(self, match_id: str, ws: WebSocket):
|
| 230 |
+
if match_id in self.active:
|
| 231 |
+
self.active[match_id].remove(ws)
|
| 232 |
+
if not self.active[match_id]:
|
| 233 |
+
del self.active[match_id]
|
| 234 |
+
|
| 235 |
+
async def broadcast(self, match_id: str, message: dict):
|
| 236 |
+
if match_id not in self.active:
|
| 237 |
+
return
|
| 238 |
+
dead = []
|
| 239 |
+
for ws in self.active[match_id]:
|
| 240 |
+
try:
|
| 241 |
+
await ws.send_json(message)
|
| 242 |
+
except Exception:
|
| 243 |
+
dead.append(ws)
|
| 244 |
+
for ws in dead:
|
| 245 |
+
self.disconnect(match_id, ws)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
manager = ConnectionManager()
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@app.websocket("/ws/odds/{match_id}")
|
| 252 |
+
async def ws_odds_stream(websocket: WebSocket, match_id: str):
|
| 253 |
+
"""
|
| 254 |
+
Real-time WebSocket endpoint for live odds ticks.
|
| 255 |
+
Clients receive two types of messages:
|
| 256 |
+
1. WsOddsTick β every 2 seconds (odds + volatility)
|
| 257 |
+
2. WsPropBetUpdate β every 5 seconds (prop-bet recalc)
|
| 258 |
+
"""
|
| 259 |
+
await manager.connect(match_id, websocket)
|
| 260 |
+
|
| 261 |
+
# Send initial snapshot immediately
|
| 262 |
+
ball_data = BallData(over=10, ball=1, total_runs=85, wickets_fallen=2,
|
| 263 |
+
striker_runs=35, striker_balls=22)
|
| 264 |
+
history = app.state.odds_history.get(match_id, [])
|
| 265 |
+
metrics = calculate_betting_metrics(0.55, ball_data, history)
|
| 266 |
+
tick = WsOddsTick(
|
| 267 |
+
match_id=match_id,
|
| 268 |
+
timestamp_ms=int(time.time() * 1000),
|
| 269 |
+
decimal_odds=metrics.decimal_odds,
|
| 270 |
+
market_volatility=metrics.market_volatility,
|
| 271 |
+
delta_label="β",
|
| 272 |
+
)
|
| 273 |
+
await websocket.send_json(tick.model_dump())
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
odds_counter = 0
|
| 277 |
+
prop_counter = 0
|
| 278 |
+
|
| 279 |
+
while True:
|
| 280 |
+
# Simulate live ball updates (replace with your real feed)
|
| 281 |
+
await asyncio.sleep(0.5) # 2 Hz tick rate
|
| 282 |
+
|
| 283 |
+
# Simulate progressing match state
|
| 284 |
+
ball_data.total_runs += int(np.random.choice([0, 1, 1, 2, 4, 6],
|
| 285 |
+
p=[0.30, 0.25, 0.20, 0.15, 0.07, 0.03]))
|
| 286 |
+
ball_data.ball += 1
|
| 287 |
+
if ball_data.ball > 6:
|
| 288 |
+
ball_data.ball = 1
|
| 289 |
+
ball_data.over += 1
|
| 290 |
+
ball_data.striker_runs = int(np.random.randint(0, 50))
|
| 291 |
+
ball_data.striker_balls = int(np.random.randint(5, 40))
|
| 292 |
+
if ball_data.over >= 20:
|
| 293 |
+
ball_data.over = 10 # Reset for demo
|
| 294 |
+
|
| 295 |
+
history = app.state.odds_history.get(match_id, [])
|
| 296 |
+
win_p = float(np.clip(0.55 + 0.01 * np.random.randn(), 0.10, 0.90))
|
| 297 |
+
metrics = calculate_betting_metrics(win_p, ball_data, history)
|
| 298 |
+
|
| 299 |
+
# Update ring buffer
|
| 300 |
+
snapshot = HistoricalOddsSnapshot(
|
| 301 |
+
timestamp_ms=int(time.time() * 1000),
|
| 302 |
+
decimal_odds=metrics.decimal_odds.home,
|
| 303 |
+
)
|
| 304 |
+
if match_id not in app.state.odds_history:
|
| 305 |
+
app.state.odds_history[match_id] = []
|
| 306 |
+
app.state.odds_history[match_id].append(snapshot)
|
| 307 |
+
if len(app.state.odds_history[match_id]) > 200:
|
| 308 |
+
app.state.odds_history[match_id] = app.state.odds_history[match_id][-200:]
|
| 309 |
+
|
| 310 |
+
# Determine delta direction
|
| 311 |
+
prev_snapshot = history[-1] if history else None
|
| 312 |
+
delta = "β"
|
| 313 |
+
if prev_snapshot:
|
| 314 |
+
diff = metrics.decimal_odds.home - prev_snapshot.decimal_odds
|
| 315 |
+
delta = "β" if diff > 0.005 else "β" if diff < -0.005 else "β"
|
| 316 |
+
|
| 317 |
+
odds_counter += 1
|
| 318 |
+
prop_counter += 1
|
| 319 |
+
|
| 320 |
+
# Broadcast odds tick every 4 ticks (~2 s)
|
| 321 |
+
if odds_counter >= 4:
|
| 322 |
+
odds_counter = 0
|
| 323 |
+
tick = WsOddsTick(
|
| 324 |
+
match_id=match_id,
|
| 325 |
+
timestamp_ms=int(time.time() * 1000),
|
| 326 |
+
decimal_odds=metrics.decimal_odds,
|
| 327 |
+
market_volatility=metrics.market_volatility,
|
| 328 |
+
delta_label=delta,
|
| 329 |
+
)
|
| 330 |
+
await manager.broadcast(match_id, tick.model_dump())
|
| 331 |
+
|
| 332 |
+
# Broadcast prop-bet update every 10 ticks (~5 s)
|
| 333 |
+
if prop_counter >= 10:
|
| 334 |
+
prop_counter = 0
|
| 335 |
+
prop_update = WsPropBetUpdate(
|
| 336 |
+
match_id=match_id,
|
| 337 |
+
timestamp_ms=int(time.time() * 1000),
|
| 338 |
+
prop_bets=metrics.prop_bets,
|
| 339 |
+
)
|
| 340 |
+
await manager.broadcast(match_id, prop_update.model_dump())
|
| 341 |
+
|
| 342 |
+
except WebSocketDisconnect:
|
| 343 |
+
manager.disconnect(match_id, websocket)
|
| 344 |
+
logger.info(f"WS client disconnected from match {match_id}")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 348 |
+
# STATIC FRONTEND
|
| 349 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
|
| 351 |
+
@app.get("/", response_class=HTMLResponse)
|
| 352 |
+
async def serve_frontend():
|
| 353 |
+
"""Serve the Pro-Analytics dashboard."""
|
| 354 |
+
index_path = FRONTEND_DIR / "index.html"
|
| 355 |
+
if index_path.exists():
|
| 356 |
+
return index_path.read_text(encoding="utf-8")
|
| 357 |
+
return HTMLResponse("<h1>Frontend not found. Place index.html in /frontend/</h1>")
|
| 358 |
+
|
| 359 |
+
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
zai/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IPL Premium Betting Insights β Pydantic Data Models
|
| 3 |
+
=====================================================
|
| 4 |
+
Defines every schema flowing through the prediction + betting pipeline.
|
| 5 |
+
All models are JSON-serialisable and typed for IDE / OpenAPI docs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import Optional
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# βββ Enums ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
class WicketMethod(str, Enum):
|
| 17 |
+
CAUGHT = "Caught"
|
| 18 |
+
BOWLED = "Bowled"
|
| 19 |
+
LBW = "LBW"
|
| 20 |
+
RUN_OUT = "Run Out"
|
| 21 |
+
STUMPED = "Stumped"
|
| 22 |
+
HIT_WICKET = "Hit Wicket"
|
| 23 |
+
RETIRED_OUT = "Retired Out"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class InningsPhase(str, Enum):
|
| 27 |
+
POWERPLAY = "powerplay" # Overs 1-6
|
| 28 |
+
MIDDLE = "middle" # Overs 7-15
|
| 29 |
+
DEATH = "death" # Overs 16-20
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# βββ Request / Context Models ββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
class BallData(BaseModel):
|
| 34 |
+
"""Raw ball-level feed consumed by the metrics engine."""
|
| 35 |
+
over: float = Field(..., ge=0, le=20, description="Current over")
|
| 36 |
+
ball: int = Field(..., ge=1, le=6, description="Ball within the over")
|
| 37 |
+
runs_scored: int = Field(0, ge=0, description="Runs scored off this ball")
|
| 38 |
+
wickets_fallen: int = Field(0, ge=0, le=10, description="Wickets down so far")
|
| 39 |
+
total_runs: int = Field(0, ge=0, description="Team total at this point")
|
| 40 |
+
striker_runs: int = Field(0, ge=0, description="Batsman on strike β runs")
|
| 41 |
+
striker_balls: int = Field(0, ge=0, description="Batsman on strike β balls faced")
|
| 42 |
+
non_striker_runs: int = Field(0, ge=0)
|
| 43 |
+
non_striker_balls: int = Field(0, ge=0)
|
| 44 |
+
is_wicket_ball: bool = False
|
| 45 |
+
wicket_method: Optional[WicketMethod] = None
|
| 46 |
+
extra_type: Optional[str] = None # wide, noball, bye, legbye
|
| 47 |
+
bowler_wickets: int = Field(0, ge=0)
|
| 48 |
+
bowler_runs: int = Field(0, ge=0)
|
| 49 |
+
bowler_overs: float = Field(0, ge=0, le=4)
|
| 50 |
+
venue: str = "neutral"
|
| 51 |
+
chasing_target: Optional[int] = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class HistoricalOddsSnapshot(BaseModel):
|
| 55 |
+
"""A single point in the odds-history timeline for volatility calc."""
|
| 56 |
+
timestamp_ms: int
|
| 57 |
+
decimal_odds: float
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# βββ Betting-Metrics Response Models βββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
class DecimalOdds(BaseModel):
|
| 62 |
+
home: float = Field(..., gt=0, description="Home team decimal odds")
|
| 63 |
+
away: float = Field(..., gt=0, description="Away team decimal odds")
|
| 64 |
+
implied_home: float = Field(..., ge=0, le=1)
|
| 65 |
+
implied_away: float = Field(..., ge=0, le=1)
|
| 66 |
+
overround: float = Field(..., description="Bookmaker margin (0 = fair)")
|
| 67 |
+
|
| 68 |
+
class Config:
|
| 69 |
+
json_schema_extra = {
|
| 70 |
+
"example": {
|
| 71 |
+
"home": 1.85, "away": 2.05,
|
| 72 |
+
"implied_home": 0.5405, "implied_away": 0.4878,
|
| 73 |
+
"overround": 0.0283
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class OverUnderLine(BaseModel):
|
| 79 |
+
market: str = Field(..., description="e.g. 'Total Runs', 'Team A Runs'")
|
| 80 |
+
line: float
|
| 81 |
+
over_odds: float
|
| 82 |
+
under_odds: float
|
| 83 |
+
projected_total: float = Field(..., description="Model-projected value")
|
| 84 |
+
confidence: float = Field(..., ge=0, le=1)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class NextWicketMethodProb(BaseModel):
|
| 88 |
+
method: WicketMethod
|
| 89 |
+
probability: float = Field(..., ge=0, le=1)
|
| 90 |
+
pressure_multiplier: float = Field(1.0, description="Adjusted by match context")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class MarketVolatility(BaseModel):
|
| 94 |
+
score: float = Field(..., ge=0, description="0 = calm, 1 = extreme")
|
| 95 |
+
label: str = Field(..., description="Low / Medium / High / Extreme")
|
| 96 |
+
tick_velocity: float = Field(..., description="Odds-shifts per minute")
|
| 97 |
+
max_shift_pct: float = Field(..., description="Largest single-tick % shift")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class PropBetPlayer(BaseModel):
|
| 101 |
+
player_name: str
|
| 102 |
+
milestone: str = Field(..., description="e.g. '50+', '100+', '3+ Wickets'")
|
| 103 |
+
probability: float = Field(..., ge=0, le=1)
|
| 104 |
+
current_value: int = Field(..., description="Runs / wickets right now")
|
| 105 |
+
balls_remaining: int = Field(..., ge=0)
|
| 106 |
+
strike_rate: Optional[float] = None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class BettingMetrics(BaseModel):
|
| 110 |
+
"""Top-level response returned by `calculate_betting_metrics`."""
|
| 111 |
+
decimal_odds: DecimalOdds
|
| 112 |
+
over_under_lines: list[OverUnderLine]
|
| 113 |
+
next_wicket_method: list[NextWicketMethodProb]
|
| 114 |
+
market_volatility: MarketVolatility
|
| 115 |
+
prop_bets: list[PropBetPlayer]
|
| 116 |
+
innings_phase: InningsPhase
|
| 117 |
+
projection_confidence: float = Field(..., ge=0, le=1)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# βββ Elite Insights Envelope (attached to /predict) βββββββββββββββββββββ
|
| 121 |
+
class EliteInsights(BaseModel):
|
| 122 |
+
betting_metrics: BettingMetrics
|
| 123 |
+
model_blend_weights: dict[str, float] = Field(
|
| 124 |
+
default_factory=lambda: {"xgboost": 0.6, "lstm": 0.4}
|
| 125 |
+
)
|
| 126 |
+
sharp_edge_pct: float = Field(
|
| 127 |
+
..., description="% edge model has over closing line"
|
| 128 |
+
)
|
| 129 |
+
last_updated_ms: int
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# βββ WebSocket Messages βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 133 |
+
class WsOddsTick(BaseModel):
|
| 134 |
+
"""Pushed every time the odds engine recalculates."""
|
| 135 |
+
match_id: str
|
| 136 |
+
timestamp_ms: int
|
| 137 |
+
decimal_odds: DecimalOdds
|
| 138 |
+
market_volatility: MarketVolatility
|
| 139 |
+
delta_label: str = Field(..., description="β / β / β")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class WsPropBetUpdate(BaseModel):
|
| 143 |
+
match_id: str
|
| 144 |
+
timestamp_ms: int
|
| 145 |
+
prop_bets: list[PropBetPlayer]
|
zai/styles.css
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
IPL Pro-Analytics β Glassmorphism Dark Theme
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
Colour System
|
| 5 |
+
βββββββββββββ
|
| 6 |
+
β’ Background: #0a0a0f β #0d1117 gradient
|
| 7 |
+
β’ Glass panels: rgba(255,255,255,0.04) blur(24px)
|
| 8 |
+
β’ Primary accent: #00ff88 (Neon Green)
|
| 9 |
+
β’ Secondary: #ffd700 (Gold)
|
| 10 |
+
β’ Danger: #ff3366
|
| 11 |
+
β’ Text: #e8e8e8 / #8b8fa3
|
| 12 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 13 |
+
|
| 14 |
+
/* βββ Reset & Base ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 15 |
+
*, *::before, *::after {
|
| 16 |
+
margin: 0;
|
| 17 |
+
padding: 0;
|
| 18 |
+
box-sizing: border-box;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
:root {
|
| 22 |
+
--bg-deep: #06060b;
|
| 23 |
+
--bg-primary: #0a0a0f;
|
| 24 |
+
--bg-secondary: #0d1117;
|
| 25 |
+
--bg-elevated: #161b22;
|
| 26 |
+
|
| 27 |
+
--glass-bg: rgba(255, 255, 255, 0.04);
|
| 28 |
+
--glass-border: rgba(255, 255, 255, 0.08);
|
| 29 |
+
--glass-hover: rgba(255, 255, 255, 0.07);
|
| 30 |
+
--glass-blur: 24px;
|
| 31 |
+
|
| 32 |
+
--neon-green: #00ff88;
|
| 33 |
+
--neon-green-dim: #00cc6a;
|
| 34 |
+
--neon-green-bg: rgba(0, 255, 136, 0.08);
|
| 35 |
+
|
| 36 |
+
--gold: #ffd700;
|
| 37 |
+
--gold-dim: #ccac00;
|
| 38 |
+
--gold-bg: rgba(255, 215, 0, 0.08);
|
| 39 |
+
|
| 40 |
+
--danger: #ff3366;
|
| 41 |
+
--danger-bg: rgba(255, 51, 102, 0.08);
|
| 42 |
+
|
| 43 |
+
--text-primary: #e8e8ec;
|
| 44 |
+
--text-secondary: #8b8fa3;
|
| 45 |
+
--text-muted: #525569;
|
| 46 |
+
|
| 47 |
+
--radius-sm: 8px;
|
| 48 |
+
--radius-md: 14px;
|
| 49 |
+
--radius-lg: 20px;
|
| 50 |
+
--radius-xl: 28px;
|
| 51 |
+
|
| 52 |
+
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 53 |
+
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
| 54 |
+
|
| 55 |
+
--shadow-glow-green: 0 0 30px rgba(0, 255, 136, 0.15), 0 0 60px rgba(0, 255, 136, 0.05);
|
| 56 |
+
--shadow-glow-gold: 0 0 30px rgba(255, 215, 0, 0.15), 0 0 60px rgba(255, 215, 0, 0.05);
|
| 57 |
+
|
| 58 |
+
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 59 |
+
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
html {
|
| 63 |
+
font-size: 16px;
|
| 64 |
+
-webkit-font-smoothing: antialiased;
|
| 65 |
+
-moz-osx-font-smoothing: grayscale;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
body {
|
| 69 |
+
font-family: var(--font-main);
|
| 70 |
+
background: linear-gradient(145deg, var(--bg-deep) 0%, var(--bg-primary) 40%, var(--bg-secondary) 100%);
|
| 71 |
+
color: var(--text-primary);
|
| 72 |
+
min-height: 100vh;
|
| 73 |
+
overflow-x: hidden;
|
| 74 |
+
line-height: 1.6;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* Animated background particles */
|
| 78 |
+
body::before {
|
| 79 |
+
content: '';
|
| 80 |
+
position: fixed;
|
| 81 |
+
inset: 0;
|
| 82 |
+
background:
|
| 83 |
+
radial-gradient(2px 2px at 20% 30%, var(--neon-green), transparent),
|
| 84 |
+
radial-gradient(2px 2px at 40% 70%, var(--gold), transparent),
|
| 85 |
+
radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.3), transparent),
|
| 86 |
+
radial-gradient(1px 1px at 80% 60%, rgba(255,255,255,0.2), transparent),
|
| 87 |
+
radial-gradient(2px 2px at 10% 80%, var(--neon-green-dim), transparent),
|
| 88 |
+
radial-gradient(1px 1px at 90% 40%, var(--gold-dim), transparent);
|
| 89 |
+
background-size: 250px 250px, 300px 300px, 200px 200px, 350px 350px, 280px 280px, 320px 320px;
|
| 90 |
+
animation: particleDrift 80s linear infinite;
|
| 91 |
+
pointer-events: none;
|
| 92 |
+
z-index: 0;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
@keyframes particleDrift {
|
| 96 |
+
0% { transform: translate(0, 0); }
|
| 97 |
+
100% { transform: translate(-250px, -250px); }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* βββ Utility Classes ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 101 |
+
.neon-green { color: var(--neon-green); }
|
| 102 |
+
.gold { color: var(--gold); }
|
| 103 |
+
.danger { color: var(--danger); }
|
| 104 |
+
|
| 105 |
+
/* βββ Glass Panel ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 106 |
+
.glass-panel {
|
| 107 |
+
background: var(--glass-bg);
|
| 108 |
+
backdrop-filter: blur(var(--glass-blur));
|
| 109 |
+
-webkit-backdrop-filter: blur(var(--glass-blur));
|
| 110 |
+
border: 1px solid var(--glass-border);
|
| 111 |
+
border-radius: var(--radius-lg);
|
| 112 |
+
padding: 1.5rem;
|
| 113 |
+
position: relative;
|
| 114 |
+
overflow: hidden;
|
| 115 |
+
transition: border-color var(--transition-smooth), box-shadow var(--transition-smooth);
|
| 116 |
+
z-index: 1;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.glass-panel::before {
|
| 120 |
+
content: '';
|
| 121 |
+
position: absolute;
|
| 122 |
+
top: 0; left: 0; right: 0;
|
| 123 |
+
height: 1px;
|
| 124 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.glass-panel:hover {
|
| 128 |
+
border-color: rgba(255, 255, 255, 0.12);
|
| 129 |
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.panel-header {
|
| 133 |
+
display: flex;
|
| 134 |
+
align-items: center;
|
| 135 |
+
justify-content: space-between;
|
| 136 |
+
margin-bottom: 1.25rem;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.panel-header h2 {
|
| 140 |
+
font-size: 1rem;
|
| 141 |
+
font-weight: 600;
|
| 142 |
+
letter-spacing: 0.02em;
|
| 143 |
+
display: flex;
|
| 144 |
+
align-items: center;
|
| 145 |
+
gap: 0.5rem;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.panel-header h2 i {
|
| 149 |
+
font-size: 0.9rem;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.panel-badge {
|
| 153 |
+
font-size: 0.7rem;
|
| 154 |
+
font-weight: 600;
|
| 155 |
+
text-transform: uppercase;
|
| 156 |
+
letter-spacing: 0.08em;
|
| 157 |
+
color: var(--text-muted);
|
| 158 |
+
background: rgba(255,255,255,0.05);
|
| 159 |
+
padding: 0.25rem 0.6rem;
|
| 160 |
+
border-radius: 100px;
|
| 161 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* βββ Top Bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 165 |
+
.top-bar {
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
justify-content: space-between;
|
| 169 |
+
padding: 1rem 2rem;
|
| 170 |
+
background: rgba(10, 10, 15, 0.8);
|
| 171 |
+
backdrop-filter: blur(20px);
|
| 172 |
+
-webkit-backdrop-filter: blur(20px);
|
| 173 |
+
border-bottom: 1px solid var(--glass-border);
|
| 174 |
+
position: sticky;
|
| 175 |
+
top: 0;
|
| 176 |
+
z-index: 100;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.top-bar__brand {
|
| 180 |
+
display: flex;
|
| 181 |
+
align-items: center;
|
| 182 |
+
gap: 0.85rem;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.brand-icon {
|
| 186 |
+
width: 42px;
|
| 187 |
+
height: 42px;
|
| 188 |
+
display: flex;
|
| 189 |
+
align-items: center;
|
| 190 |
+
justify-content: center;
|
| 191 |
+
background: linear-gradient(135deg, var(--neon-green), var(--gold));
|
| 192 |
+
border-radius: var(--radius-md);
|
| 193 |
+
font-size: 1.1rem;
|
| 194 |
+
color: var(--bg-deep);
|
| 195 |
+
animation: iconPulse 3s ease-in-out infinite;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@keyframes iconPulse {
|
| 199 |
+
0%, 100% { box-shadow: 0 0 15px rgba(0,255,136,0.3); }
|
| 200 |
+
50% { box-shadow: 0 0 25px rgba(255,215,0,0.3); }
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.brand-title {
|
| 204 |
+
font-size: 1.2rem;
|
| 205 |
+
font-weight: 800;
|
| 206 |
+
letter-spacing: -0.02em;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.brand-subtitle {
|
| 210 |
+
font-size: 0.7rem;
|
| 211 |
+
color: var(--text-muted);
|
| 212 |
+
letter-spacing: 0.04em;
|
| 213 |
+
text-transform: uppercase;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.top-bar__controls {
|
| 217 |
+
display: flex;
|
| 218 |
+
align-items: center;
|
| 219 |
+
gap: 1.25rem;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.match-selector {
|
| 223 |
+
display: flex;
|
| 224 |
+
align-items: center;
|
| 225 |
+
gap: 0.5rem;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.match-selector label {
|
| 229 |
+
font-size: 0.75rem;
|
| 230 |
+
color: var(--text-muted);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.match-selector select {
|
| 234 |
+
background: var(--bg-elevated);
|
| 235 |
+
border: 1px solid var(--glass-border);
|
| 236 |
+
color: var(--text-primary);
|
| 237 |
+
padding: 0.4rem 0.75rem;
|
| 238 |
+
border-radius: var(--radius-sm);
|
| 239 |
+
font-family: var(--font-main);
|
| 240 |
+
font-size: 0.8rem;
|
| 241 |
+
cursor: pointer;
|
| 242 |
+
outline: none;
|
| 243 |
+
transition: border-color var(--transition-fast);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.match-selector select:focus {
|
| 247 |
+
border-color: var(--neon-green);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.connection-badge {
|
| 251 |
+
display: flex;
|
| 252 |
+
align-items: center;
|
| 253 |
+
gap: 0.4rem;
|
| 254 |
+
font-size: 0.75rem;
|
| 255 |
+
font-weight: 500;
|
| 256 |
+
padding: 0.35rem 0.75rem;
|
| 257 |
+
border-radius: 100px;
|
| 258 |
+
background: rgba(255,255,255,0.04);
|
| 259 |
+
border: 1px solid var(--glass-border);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.conn-dot {
|
| 263 |
+
width: 8px;
|
| 264 |
+
height: 8px;
|
| 265 |
+
border-radius: 50%;
|
| 266 |
+
background: var(--text-muted);
|
| 267 |
+
animation: blink 2s infinite;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.connection-badge.connected .conn-dot {
|
| 271 |
+
background: var(--neon-green);
|
| 272 |
+
box-shadow: 0 0 8px var(--neon-green);
|
| 273 |
+
animation: none;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.connection-badge.disconnected .conn-dot {
|
| 277 |
+
background: var(--danger);
|
| 278 |
+
box-shadow: 0 0 8px var(--danger);
|
| 279 |
+
animation: none;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
@keyframes blink {
|
| 283 |
+
0%, 100% { opacity: 1; }
|
| 284 |
+
50% { opacity: 0.3; }
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/* βββ Dashboard Grid βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 288 |
+
.dashboard-grid {
|
| 289 |
+
display: grid;
|
| 290 |
+
grid-template-columns: 1fr 1fr;
|
| 291 |
+
grid-template-rows: auto;
|
| 292 |
+
gap: 1.25rem;
|
| 293 |
+
padding: 1.5rem 2rem;
|
| 294 |
+
max-width: 1440px;
|
| 295 |
+
margin: 0 auto;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/* Panel 1 β Odds: spans full width */
|
| 299 |
+
.odds-panel {
|
| 300 |
+
grid-column: 1 / -1;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/* Panel 2 β Volatility: 1 col */
|
| 304 |
+
.volatility-panel {
|
| 305 |
+
grid-column: 1;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
/* Panel 3 β Over/Under: 1 col */
|
| 309 |
+
.ou-panel {
|
| 310 |
+
grid-column: 2;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/* Panel 4 β Wicket Method */
|
| 314 |
+
.wicket-panel {
|
| 315 |
+
grid-column: 1;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/* Panel 5 β Heatmap: spans full width */
|
| 319 |
+
.heatmap-panel {
|
| 320 |
+
grid-column: 1 / -1;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/* Panel 6 β Scorecard */
|
| 324 |
+
.scorecard-panel {
|
| 325 |
+
grid-column: 1 / -1;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/* βββ Live Odds Panel ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 329 |
+
.odds-body {
|
| 330 |
+
display: flex;
|
| 331 |
+
align-items: center;
|
| 332 |
+
justify-content: center;
|
| 333 |
+
gap: 2rem;
|
| 334 |
+
margin-bottom: 1rem;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.odds-team {
|
| 338 |
+
flex: 1;
|
| 339 |
+
text-align: center;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.team-label {
|
| 343 |
+
font-size: 1.6rem;
|
| 344 |
+
font-weight: 800;
|
| 345 |
+
letter-spacing: 0.05em;
|
| 346 |
+
margin-bottom: 0.25rem;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.odds-value {
|
| 350 |
+
font-family: var(--font-mono);
|
| 351 |
+
font-size: 2.8rem;
|
| 352 |
+
font-weight: 700;
|
| 353 |
+
line-height: 1.1;
|
| 354 |
+
transition: color var(--transition-fast);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.implied {
|
| 358 |
+
font-size: 0.75rem;
|
| 359 |
+
color: var(--text-muted);
|
| 360 |
+
margin-top: 0.2rem;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.odds-bar-container {
|
| 364 |
+
height: 6px;
|
| 365 |
+
background: rgba(255,255,255,0.06);
|
| 366 |
+
border-radius: 100px;
|
| 367 |
+
margin-top: 0.75rem;
|
| 368 |
+
overflow: hidden;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.odds-bar {
|
| 372 |
+
height: 100%;
|
| 373 |
+
border-radius: 100px;
|
| 374 |
+
transition: width var(--transition-smooth);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.odds-bar--home {
|
| 378 |
+
background: linear-gradient(90deg, var(--neon-green), var(--neon-green-dim));
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.odds-bar--away {
|
| 382 |
+
background: linear-gradient(90deg, var(--gold-dim), var(--gold));
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.odds-vs {
|
| 386 |
+
font-size: 0.85rem;
|
| 387 |
+
font-weight: 700;
|
| 388 |
+
color: var(--text-muted);
|
| 389 |
+
letter-spacing: 0.1em;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.odds-meta {
|
| 393 |
+
display: flex;
|
| 394 |
+
justify-content: center;
|
| 395 |
+
gap: 1.5rem;
|
| 396 |
+
font-size: 0.72rem;
|
| 397 |
+
color: var(--text-muted);
|
| 398 |
+
padding-top: 1rem;
|
| 399 |
+
border-top: 1px solid var(--glass-border);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.odds-meta strong {
|
| 403 |
+
color: var(--text-secondary);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.live-badge {
|
| 407 |
+
display: flex;
|
| 408 |
+
align-items: center;
|
| 409 |
+
gap: 0.4rem;
|
| 410 |
+
font-size: 0.7rem;
|
| 411 |
+
font-weight: 700;
|
| 412 |
+
color: var(--danger);
|
| 413 |
+
text-transform: uppercase;
|
| 414 |
+
letter-spacing: 0.08em;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.pulse-dot {
|
| 418 |
+
width: 8px;
|
| 419 |
+
height: 8px;
|
| 420 |
+
border-radius: 50%;
|
| 421 |
+
background: var(--danger);
|
| 422 |
+
animation: pulse 1.5s ease-in-out infinite;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
@keyframes pulse {
|
| 426 |
+
0%, 100% { transform: scale(1); opacity: 1; }
|
| 427 |
+
50% { transform: scale(1.5); opacity: 0.5; }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* βββ Volatility Gauge βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 431 |
+
.gauge-container {
|
| 432 |
+
display: flex;
|
| 433 |
+
justify-content: center;
|
| 434 |
+
padding: 0.5rem 0;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.gauge-svg {
|
| 438 |
+
width: 220px;
|
| 439 |
+
height: 140px;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.gauge-bg {
|
| 443 |
+
stroke: rgba(255,255,255,0.06);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.gauge-fill {
|
| 447 |
+
transition: stroke-dasharray 0.8s cubic-bezier(0.4, 0, 0.2, 1),
|
| 448 |
+
stroke 0.5s ease;
|
| 449 |
+
filter: drop-shadow(0 0 8px currentColor);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.gauge-value {
|
| 453 |
+
fill: var(--text-primary);
|
| 454 |
+
font-family: var(--font-mono);
|
| 455 |
+
font-size: 28px;
|
| 456 |
+
font-weight: 700;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.gauge-label {
|
| 460 |
+
fill: var(--text-secondary);
|
| 461 |
+
font-size: 13px;
|
| 462 |
+
font-weight: 500;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.volatility-stats {
|
| 466 |
+
display: grid;
|
| 467 |
+
grid-template-columns: repeat(3, 1fr);
|
| 468 |
+
gap: 0.75rem;
|
| 469 |
+
margin-top: 1rem;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.stat-item {
|
| 473 |
+
text-align: center;
|
| 474 |
+
padding: 0.6rem;
|
| 475 |
+
background: rgba(255,255,255,0.03);
|
| 476 |
+
border-radius: var(--radius-sm);
|
| 477 |
+
border: 1px solid rgba(255,255,255,0.04);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.stat-label {
|
| 481 |
+
display: block;
|
| 482 |
+
font-size: 0.65rem;
|
| 483 |
+
color: var(--text-muted);
|
| 484 |
+
text-transform: uppercase;
|
| 485 |
+
letter-spacing: 0.06em;
|
| 486 |
+
margin-bottom: 0.2rem;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.stat-value {
|
| 490 |
+
font-family: var(--font-mono);
|
| 491 |
+
font-size: 1rem;
|
| 492 |
+
font-weight: 600;
|
| 493 |
+
color: var(--text-primary);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/* βββ Over/Under Table βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 497 |
+
.table-wrapper {
|
| 498 |
+
overflow-x: auto;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.styled-table {
|
| 502 |
+
width: 100%;
|
| 503 |
+
border-collapse: separate;
|
| 504 |
+
border-spacing: 0;
|
| 505 |
+
font-size: 0.82rem;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.styled-table thead th {
|
| 509 |
+
text-align: left;
|
| 510 |
+
font-size: 0.68rem;
|
| 511 |
+
font-weight: 600;
|
| 512 |
+
color: var(--text-muted);
|
| 513 |
+
text-transform: uppercase;
|
| 514 |
+
letter-spacing: 0.06em;
|
| 515 |
+
padding: 0.6rem 0.75rem;
|
| 516 |
+
border-bottom: 1px solid var(--glass-border);
|
| 517 |
+
white-space: nowrap;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.odds-col {
|
| 521 |
+
font-weight: 400;
|
| 522 |
+
color: var(--text-muted);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.styled-table tbody td {
|
| 526 |
+
padding: 0.65rem 0.75rem;
|
| 527 |
+
border-bottom: 1px solid rgba(255,255,255,0.03);
|
| 528 |
+
font-family: var(--font-mono);
|
| 529 |
+
font-size: 0.8rem;
|
| 530 |
+
vertical-align: middle;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.styled-table tbody tr {
|
| 534 |
+
transition: background var(--transition-fast);
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.styled-table tbody tr:hover {
|
| 538 |
+
background: rgba(255,255,255,0.03);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.styled-table tbody td:first-child {
|
| 542 |
+
font-family: var(--font-main);
|
| 543 |
+
font-weight: 500;
|
| 544 |
+
color: var(--text-secondary);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.update-time {
|
| 548 |
+
font-size: 0.65rem;
|
| 549 |
+
color: var(--text-muted);
|
| 550 |
+
font-family: var(--font-mono);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.skeleton-row td {
|
| 554 |
+
text-align: center;
|
| 555 |
+
color: var(--text-muted);
|
| 556 |
+
font-family: var(--font-main);
|
| 557 |
+
font-style: italic;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/* βββ Wicket Method ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 561 |
+
.wicket-methods {
|
| 562 |
+
display: flex;
|
| 563 |
+
flex-direction: column;
|
| 564 |
+
gap: 0.6rem;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.wicket-row {
|
| 568 |
+
display: flex;
|
| 569 |
+
align-items: center;
|
| 570 |
+
gap: 0.75rem;
|
| 571 |
+
padding: 0.6rem 0.75rem;
|
| 572 |
+
background: rgba(255,255,255,0.02);
|
| 573 |
+
border-radius: var(--radius-sm);
|
| 574 |
+
border: 1px solid rgba(255,255,255,0.04);
|
| 575 |
+
transition: all var(--transition-fast);
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.wicket-row:hover {
|
| 579 |
+
background: rgba(255,255,255,0.05);
|
| 580 |
+
border-color: rgba(255,255,255,0.08);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.wicket-method-name {
|
| 584 |
+
font-size: 0.82rem;
|
| 585 |
+
font-weight: 500;
|
| 586 |
+
color: var(--text-secondary);
|
| 587 |
+
min-width: 80px;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.wicket-prob-bar {
|
| 591 |
+
flex: 1;
|
| 592 |
+
height: 8px;
|
| 593 |
+
background: rgba(255,255,255,0.06);
|
| 594 |
+
border-radius: 100px;
|
| 595 |
+
overflow: hidden;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.wicket-prob-fill {
|
| 599 |
+
height: 100%;
|
| 600 |
+
border-radius: 100px;
|
| 601 |
+
transition: width var(--transition-smooth);
|
| 602 |
+
background: linear-gradient(90deg, var(--neon-green-dim), var(--neon-green));
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.wicket-prob-fill.top-method {
|
| 606 |
+
background: linear-gradient(90deg, var(--gold-dim), var(--gold));
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.wicket-prob-value {
|
| 610 |
+
font-family: var(--font-mono);
|
| 611 |
+
font-size: 0.82rem;
|
| 612 |
+
font-weight: 600;
|
| 613 |
+
color: var(--text-primary);
|
| 614 |
+
min-width: 50px;
|
| 615 |
+
text-align: right;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.wicket-pressure {
|
| 619 |
+
font-size: 0.65rem;
|
| 620 |
+
font-family: var(--font-mono);
|
| 621 |
+
color: var(--neon-green);
|
| 622 |
+
background: var(--neon-green-bg);
|
| 623 |
+
padding: 0.15rem 0.45rem;
|
| 624 |
+
border-radius: 100px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.wicket-empty {
|
| 628 |
+
text-align: center;
|
| 629 |
+
color: var(--text-muted);
|
| 630 |
+
font-size: 0.85rem;
|
| 631 |
+
padding: 1rem;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
/* βββ Prop-Bet Heatmap βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 635 |
+
.heatmap-grid {
|
| 636 |
+
display: grid;
|
| 637 |
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
| 638 |
+
gap: 0.75rem;
|
| 639 |
+
min-height: 80px;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.heatmap-cell {
|
| 643 |
+
padding: 1rem;
|
| 644 |
+
border-radius: var(--radius-md);
|
| 645 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 646 |
+
position: relative;
|
| 647 |
+
overflow: hidden;
|
| 648 |
+
transition: all var(--transition-smooth);
|
| 649 |
+
cursor: default;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.heatmap-cell:hover {
|
| 653 |
+
transform: translateY(-2px);
|
| 654 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.heatmap-cell::after {
|
| 658 |
+
content: '';
|
| 659 |
+
position: absolute;
|
| 660 |
+
top: 0; right: 0;
|
| 661 |
+
width: 60px;
|
| 662 |
+
height: 60px;
|
| 663 |
+
border-radius: 50%;
|
| 664 |
+
background: inherit;
|
| 665 |
+
filter: blur(25px);
|
| 666 |
+
opacity: 0.4;
|
| 667 |
+
pointer-events: none;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.heatmap-cell .cell-player {
|
| 671 |
+
font-size: 0.75rem;
|
| 672 |
+
font-weight: 600;
|
| 673 |
+
color: var(--text-primary);
|
| 674 |
+
margin-bottom: 0.2rem;
|
| 675 |
+
display: flex;
|
| 676 |
+
align-items: center;
|
| 677 |
+
gap: 0.4rem;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.heatmap-cell .cell-player i {
|
| 681 |
+
font-size: 0.65rem;
|
| 682 |
+
opacity: 0.6;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.heatmap-cell .cell-milestone {
|
| 686 |
+
font-size: 0.65rem;
|
| 687 |
+
color: var(--text-muted);
|
| 688 |
+
margin-bottom: 0.6rem;
|
| 689 |
+
text-transform: uppercase;
|
| 690 |
+
letter-spacing: 0.04em;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.heatmap-cell .cell-prob {
|
| 694 |
+
font-family: var(--font-mono);
|
| 695 |
+
font-size: 1.4rem;
|
| 696 |
+
font-weight: 700;
|
| 697 |
+
margin-bottom: 0.3rem;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
.heatmap-cell .cell-stats {
|
| 701 |
+
font-size: 0.65rem;
|
| 702 |
+
color: var(--text-muted);
|
| 703 |
+
display: flex;
|
| 704 |
+
gap: 0.6rem;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.heatmap-cell .cell-stats span {
|
| 708 |
+
display: flex;
|
| 709 |
+
align-items: center;
|
| 710 |
+
gap: 0.2rem;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
/* Heatmap colour bands */
|
| 714 |
+
.heatmap-cell.prob-very-low { background: rgba(255, 51, 51, 0.08); border-color: rgba(255, 51, 51, 0.15); }
|
| 715 |
+
.heatmap-cell.prob-low { background: rgba(255, 136, 0, 0.08); border-color: rgba(255, 136, 0, 0.15); }
|
| 716 |
+
.heatmap-cell.prob-medium { background: rgba(255, 215, 0, 0.08); border-color: rgba(255, 215, 0, 0.15); }
|
| 717 |
+
.heatmap-cell.prob-high { background: rgba(136, 255, 68, 0.08); border-color: rgba(136, 255, 68, 0.15); }
|
| 718 |
+
.heatmap-cell.prob-very-high { background: rgba(0, 255, 136, 0.08); border-color: rgba(0, 255, 136, 0.20); box-shadow: 0 0 20px rgba(0, 255, 136, 0.06); }
|
| 719 |
+
|
| 720 |
+
.heatmap-cell.prob-very-low .cell-prob { color: #ff5555; }
|
| 721 |
+
.heatmap-cell.prob-low .cell-prob { color: #ff8800; }
|
| 722 |
+
.heatmap-cell.prob-medium .cell-prob { color: var(--gold); }
|
| 723 |
+
.heatmap-cell.prob-high .cell-prob { color: #88ff44; }
|
| 724 |
+
.heatmap-cell.prob-very-high .cell-prob { color: var(--neon-green); }
|
| 725 |
+
|
| 726 |
+
.heatmap-legend {
|
| 727 |
+
display: flex;
|
| 728 |
+
justify-content: center;
|
| 729 |
+
gap: 1rem;
|
| 730 |
+
margin-top: 1rem;
|
| 731 |
+
padding-top: 0.75rem;
|
| 732 |
+
border-top: 1px solid var(--glass-border);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.legend-item {
|
| 736 |
+
display: flex;
|
| 737 |
+
align-items: center;
|
| 738 |
+
gap: 0.35rem;
|
| 739 |
+
font-size: 0.65rem;
|
| 740 |
+
color: var(--text-muted);
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.legend-swatch {
|
| 744 |
+
width: 10px;
|
| 745 |
+
height: 10px;
|
| 746 |
+
border-radius: 3px;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.heatmap-empty {
|
| 750 |
+
grid-column: 1 / -1;
|
| 751 |
+
text-align: center;
|
| 752 |
+
color: var(--text-muted);
|
| 753 |
+
font-size: 0.85rem;
|
| 754 |
+
padding: 2rem;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* βββ Scorecard ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 758 |
+
.scorecard-body {
|
| 759 |
+
display: flex;
|
| 760 |
+
align-items: center;
|
| 761 |
+
justify-content: space-between;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.score-main {
|
| 765 |
+
text-align: left;
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
.score-runs {
|
| 769 |
+
font-family: var(--font-mono);
|
| 770 |
+
font-size: 3rem;
|
| 771 |
+
font-weight: 800;
|
| 772 |
+
color: var(--text-primary);
|
| 773 |
+
line-height: 1;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.score-wickets {
|
| 777 |
+
font-size: 1.8rem;
|
| 778 |
+
color: var(--text-secondary);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.score-overs {
|
| 782 |
+
font-size: 0.85rem;
|
| 783 |
+
color: var(--text-muted);
|
| 784 |
+
margin-top: 0.15rem;
|
| 785 |
+
font-family: var(--font-mono);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.score-extras {
|
| 789 |
+
display: grid;
|
| 790 |
+
grid-template-columns: repeat(3, 1fr);
|
| 791 |
+
gap: 0.75rem;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.extra-stat {
|
| 795 |
+
text-align: center;
|
| 796 |
+
padding: 0.75rem 1.25rem;
|
| 797 |
+
background: rgba(255,255,255,0.03);
|
| 798 |
+
border-radius: var(--radius-sm);
|
| 799 |
+
border: 1px solid rgba(255,255,255,0.05);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.extra-stat span {
|
| 803 |
+
display: block;
|
| 804 |
+
font-size: 0.65rem;
|
| 805 |
+
color: var(--text-muted);
|
| 806 |
+
text-transform: uppercase;
|
| 807 |
+
letter-spacing: 0.06em;
|
| 808 |
+
margin-bottom: 0.15rem;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.extra-stat strong {
|
| 812 |
+
font-family: var(--font-mono);
|
| 813 |
+
font-size: 1.05rem;
|
| 814 |
+
color: var(--text-primary);
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
/* βββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 818 |
+
.app-footer {
|
| 819 |
+
text-align: center;
|
| 820 |
+
padding: 1rem 2rem;
|
| 821 |
+
font-size: 0.7rem;
|
| 822 |
+
color: var(--text-muted);
|
| 823 |
+
border-top: 1px solid var(--glass-border);
|
| 824 |
+
display: flex;
|
| 825 |
+
align-items: center;
|
| 826 |
+
justify-content: center;
|
| 827 |
+
gap: 0.6rem;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
/* βββ Odds Flash Animations ββββββββββββββββββββββββββββββββββββββββββ */
|
| 831 |
+
@keyframes oddsFlashGreen {
|
| 832 |
+
0% { color: var(--neon-green); text-shadow: 0 0 15px var(--neon-green); }
|
| 833 |
+
100% { color: var(--text-primary); text-shadow: none; }
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
@keyframes oddsFlashGold {
|
| 837 |
+
0% { color: var(--gold); text-shadow: 0 0 15px var(--gold); }
|
| 838 |
+
100% { color: var(--text-primary); text-shadow: none; }
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.odds-flash-up {
|
| 842 |
+
animation: oddsFlashGreen 1s ease-out;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
.odds-flash-down {
|
| 846 |
+
animation: oddsFlashGold 1s ease-out;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
/* βββ Responsive βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 850 |
+
@media (max-width: 1024px) {
|
| 851 |
+
.dashboard-grid {
|
| 852 |
+
grid-template-columns: 1fr;
|
| 853 |
+
padding: 1rem;
|
| 854 |
+
gap: 1rem;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.odds-panel,
|
| 858 |
+
.heatmap-panel,
|
| 859 |
+
.scorecard-panel,
|
| 860 |
+
.volatility-panel,
|
| 861 |
+
.ou-panel,
|
| 862 |
+
.wicket-panel {
|
| 863 |
+
grid-column: 1;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
.top-bar {
|
| 867 |
+
flex-direction: column;
|
| 868 |
+
gap: 0.75rem;
|
| 869 |
+
padding: 0.75rem 1rem;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.top-bar__controls {
|
| 873 |
+
width: 100%;
|
| 874 |
+
justify-content: space-between;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.odds-value {
|
| 878 |
+
font-size: 2rem;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.score-runs {
|
| 882 |
+
font-size: 2.2rem;
|
| 883 |
+
}
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
@media (max-width: 640px) {
|
| 887 |
+
.odds-body {
|
| 888 |
+
flex-direction: column;
|
| 889 |
+
gap: 1rem;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
.odds-vs {
|
| 893 |
+
display: none;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
.odds-meta {
|
| 897 |
+
flex-wrap: wrap;
|
| 898 |
+
gap: 0.75rem;
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
.volatility-stats {
|
| 902 |
+
grid-template-columns: 1fr;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.scorecard-body {
|
| 906 |
+
flex-direction: column;
|
| 907 |
+
gap: 1rem;
|
| 908 |
+
text-align: center;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
.score-main {
|
| 912 |
+
text-align: center;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
.heatmap-grid {
|
| 916 |
+
grid-template-columns: 1fr;
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
.heatmap-legend {
|
| 920 |
+
flex-wrap: wrap;
|
| 921 |
+
gap: 0.5rem;
|
| 922 |
+
}
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
/* βββ Scrollbar ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 926 |
+
::-webkit-scrollbar {
|
| 927 |
+
width: 6px;
|
| 928 |
+
height: 6px;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
::-webkit-scrollbar-track {
|
| 932 |
+
background: transparent;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
::-webkit-scrollbar-thumb {
|
| 936 |
+
background: rgba(255,255,255,0.1);
|
| 937 |
+
border-radius: 100px;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
::-webkit-scrollbar-thumb:hover {
|
| 941 |
+
background: rgba(255,255,255,0.2);
|
| 942 |
+
}
|