Biswajeet Arukha commited on
Commit
38e3aca
Β·
1 Parent(s): ca15384

Integration of Elite Betting Insights and Pro Dashboard

Browse files
.gitignore CHANGED
@@ -6,10 +6,10 @@ __pycache__/
6
  .venv312/
7
 
8
  # Data & Datasets
9
- data/match_jsons/
10
- dataset/archive*/
11
- dataset/*.csv
12
- dataset/enhanced/*.csv
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-mesa-glx \
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/predictionsingle..." -ForegroundColor Green
15
- hf upload zeroday01/predictionsingle . --exclude ".venv/*" ".venv312/*" ".pytest_cache/*" ".git/*" "__pycache__/*" "*.log" "pytest_*.txt"
 
 
 
 
 
 
 
 
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: 8080
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/predictionsingle")
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
- local_path = hf_hub_download(repo_id=repo_id, filename=file_name)
 
 
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;">&larr; 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> &lt; 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> &gt; 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
+ }