261 lines
8.5 KiB
Python
Executable File
261 lines
8.5 KiB
Python
Executable File
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import asyncio
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from dotenv import load_dotenv
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from models.basketball_v25 import get_basketball_v25_predictor
|
|
from services.single_match_orchestrator import get_single_match_orchestrator
|
|
from data.database import dispose_engine
|
|
|
|
load_dotenv()
|
|
|
|
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
|
|
|
|
class CouponRequest(BaseModel):
|
|
match_ids: list[str]
|
|
strategy: str | None = "BALANCED"
|
|
max_matches: int | None = None
|
|
min_confidence: float | None = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
try:
|
|
print("🚀 Initializing V25 orchestrator...", flush=True)
|
|
get_single_match_orchestrator()
|
|
print("✅ V25 orchestrator ready", flush=True)
|
|
except Exception as error:
|
|
print(f"❌ Failed to initialize orchestrator: {error}", flush=True)
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
|
|
yield
|
|
|
|
# Cleanup async DB connections on shutdown
|
|
await dispose_engine()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Suggest-Bet AI Engine",
|
|
version="25.0.0",
|
|
description="V25 Single Match Prediction Package API",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
def _parse_cors_origins() -> list[str]:
|
|
raw = os.getenv("CORS_ALLOW_ORIGINS", "").strip()
|
|
if raw:
|
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
# Dev-safe defaults + production domains.
|
|
return [
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:3001",
|
|
"http://127.0.0.1:3001",
|
|
"http://localhost:3005",
|
|
"http://127.0.0.1:3005",
|
|
"https://ui-suggestbet.bilgich.com",
|
|
"https://suggestbet.bilgich.com",
|
|
"https://iddaai.com",
|
|
"https://www.iddaai.com",
|
|
]
|
|
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_parse_cors_origins(),
|
|
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def global_exception_handler(_: Request, exc: Exception):
|
|
import traceback
|
|
|
|
print(f"💥 ERROR: {exc}", flush=True)
|
|
traceback.print_exc()
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"message": f"Internal Server Error: {str(exc)}"},
|
|
)
|
|
|
|
|
|
@app.get("/")
|
|
def read_root() -> dict[str, Any]:
|
|
return {
|
|
"status": "Suggest-Bet AI Engine v25",
|
|
"engine": "V25 Single Match Orchestrator",
|
|
"routes": [
|
|
"POST /v20plus/analyze/{match_id}",
|
|
"GET /v20plus/analyze-htms/{match_id}",
|
|
"GET /v20plus/analyze-htft/{match_id}",
|
|
"GET /v20plus/reversal-watchlist",
|
|
"POST /v20plus/coupon",
|
|
"GET /v20plus/daily-banker",
|
|
],
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health_check() -> dict[str, Any]:
|
|
try:
|
|
get_single_match_orchestrator()
|
|
basketball_predictor = get_basketball_v25_predictor()
|
|
basketball_readiness = basketball_predictor.readiness_summary()
|
|
ready = bool(basketball_readiness["fully_loaded"])
|
|
return {
|
|
"status": "healthy" if ready else "degraded",
|
|
"engine": "v25.main",
|
|
"ready": ready,
|
|
"basketball_v25": basketball_readiness,
|
|
}
|
|
except Exception as error:
|
|
return {"status": "unhealthy", "ready": False, "error": str(error)}
|
|
|
|
|
|
@app.post("/v20plus/analyze/{match_id}")
|
|
async def analyze_match_v20plus(match_id: str) -> dict[str, Any]:
|
|
orchestrator = get_single_match_orchestrator()
|
|
result = orchestrator.analyze_match(match_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
|
|
return result
|
|
|
|
|
|
@app.get("/v20plus/analyze-htms/{match_id}")
|
|
async def analyze_match_htms_v20plus(match_id: str) -> dict[str, Any]:
|
|
orchestrator = get_single_match_orchestrator()
|
|
result = orchestrator.analyze_match_htms(match_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
|
|
return result
|
|
|
|
|
|
@app.get("/v20plus/analyze-htft/{match_id}")
|
|
async def analyze_match_htft_v20plus(match_id: str, timeout_sec: int = 30) -> dict[str, Any]:
|
|
# Small, explicit endpoint for HT/FT inspection and debugging in FE/Postman.
|
|
if timeout_sec < 3 or timeout_sec > 120:
|
|
raise HTTPException(status_code=400, detail="timeout_sec must be between 3 and 120")
|
|
|
|
orchestrator = get_single_match_orchestrator()
|
|
started_at = time.time()
|
|
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
asyncio.to_thread(orchestrator.analyze_match, match_id),
|
|
timeout=float(timeout_sec),
|
|
)
|
|
except asyncio.TimeoutError as error:
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail=f"Analyze timeout after {timeout_sec}s for match_id={match_id}",
|
|
) from error
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
|
|
|
|
risk = result.get("risk", {})
|
|
market_board = result.get("market_board", {})
|
|
htft_probs = market_board.get("HTFT", {}).get("probs", {}) or risk.get("ht_ft_probs", {})
|
|
top_reversal_pick = None
|
|
top_reversal_prob = 0.0
|
|
if htft_probs:
|
|
prob_12 = float(htft_probs.get("1/2", 0.0))
|
|
prob_21 = float(htft_probs.get("2/1", 0.0))
|
|
if prob_21 >= prob_12:
|
|
top_reversal_pick = "2/1"
|
|
top_reversal_prob = prob_21
|
|
else:
|
|
top_reversal_pick = "1/2"
|
|
top_reversal_prob = prob_12
|
|
|
|
overall_htft_pick = None
|
|
overall_htft_prob = 0.0
|
|
if htft_probs:
|
|
overall_htft_pick, overall_htft_prob = max(
|
|
htft_probs.items(),
|
|
key=lambda item: float(item[1]),
|
|
)
|
|
return {
|
|
"engine": "v25.main",
|
|
"match_info": result.get("match_info", {}),
|
|
"timing_ms": int((time.time() - started_at) * 1000),
|
|
"ht_ft_probs": htft_probs,
|
|
"top_reversal_pick": top_reversal_pick,
|
|
"top_reversal_prob": round(float(top_reversal_prob), 4),
|
|
"overall_htft_pick": overall_htft_pick,
|
|
"overall_htft_pick_prob": round(float(overall_htft_prob), 4),
|
|
"surprise_hunter": result.get("surprise_hunter", {}),
|
|
"ht_ft_reversal_radar": result.get("ht_ft_reversal_radar", {}),
|
|
"first_half_result": result.get("market_board", {}).get("first_half_result", {}),
|
|
"main_pick": result.get("main_pick", {}),
|
|
"bet_summary": result.get("bet_summary", {}),
|
|
}
|
|
|
|
|
|
@app.post("/v20plus/coupon")
|
|
async def generate_coupon_v20plus(request: CouponRequest) -> dict[str, Any]:
|
|
orchestrator = get_single_match_orchestrator()
|
|
return orchestrator.build_coupon(
|
|
match_ids=request.match_ids,
|
|
strategy=request.strategy or "BALANCED",
|
|
max_matches=request.max_matches,
|
|
min_confidence=request.min_confidence,
|
|
)
|
|
|
|
|
|
@app.get("/v20plus/daily-banker")
|
|
async def get_daily_banker_v20plus(count: int = 3) -> dict[str, Any]:
|
|
if count < 1:
|
|
raise HTTPException(status_code=400, detail="count must be >= 1")
|
|
|
|
orchestrator = get_single_match_orchestrator()
|
|
bankers = orchestrator.get_daily_bankers(count=count)
|
|
return {"count": len(bankers), "bankers": bankers}
|
|
|
|
@app.get("/v20plus/reversal-watchlist")
|
|
async def get_reversal_watchlist_v20plus(
|
|
count: int = 20,
|
|
horizon_hours: int = 72,
|
|
min_score: float = 45.0,
|
|
top_leagues_only: bool = False,
|
|
) -> dict[str, Any]:
|
|
if count < 1 or count > 100:
|
|
raise HTTPException(status_code=400, detail="count must be between 1 and 100")
|
|
if horizon_hours < 6 or horizon_hours > 168:
|
|
raise HTTPException(status_code=400, detail="horizon_hours must be between 6 and 168")
|
|
if min_score < 0 or min_score > 100:
|
|
raise HTTPException(status_code=400, detail="min_score must be between 0 and 100")
|
|
|
|
orchestrator = get_single_match_orchestrator()
|
|
return orchestrator.get_reversal_watchlist(
|
|
count=count,
|
|
horizon_hours=horizon_hours,
|
|
min_score=min_score,
|
|
top_leagues_only=top_leagues_only,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(os.getenv("PORT", "8000"))
|
|
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
|