import asyncio import json import logging import os import contextlib from collections import defaultdict from datetime import datetime from typing import Any, Mapping from uuid import uuid4 from sqlalchemy import asc, desc, select, func from sqlalchemy.orm import aliased from sqlalchemy.exc import SQLAlchemyError from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from pydantic import BaseModel, Field from redis.asyncio import Redis from .db.base import SessionFactory from .db.models import Bookmaker, Match, OddsHistory, SmartMoneyFlow, Team, Venue from .analytics import ( BacktestTradeRecord, KellyResult, PropMetric, StrategyFilter, EnsembleModelArtifact, FEATURE_COLUMNS, LedgerSummary, MatchConditionSignal, ProofOfYieldStore, ProofYieldRecord, build_default_ensemble_artifact, calculate_model_edges, evaluate_match_conditions, evaluate_reverse_line_movement, model_predict_probabilities, normalize_feature_payload, train_match_outcome_ensemble, calculate_kelly_fraction, evaluate_top_edge, filter_trades, PlayerPropsProfile, run_flat_stake_backtest, simulate_player_prop_probability, PoissonMatchPredictor, ) logging.basicConfig(level=logging.INFO) logger = logging.getLogger('fifa2026-ws') class ConnectionManager: def __init__(self) -> None: self.match_rooms: dict[str, set[WebSocket]] = defaultdict(set) self.all_connections: set[WebSocket] = set() async def connect(self, websocket: WebSocket, match_id: str) -> None: await websocket.accept() self.match_rooms[match_id].add(websocket) self.all_connections.add(websocket) async def disconnect(self, websocket: WebSocket, match_id: str) -> None: self.match_rooms[match_id].discard(websocket) self.all_connections.discard(websocket) if not self.match_rooms[match_id]: self.match_rooms.pop(match_id, None) async def broadcast_to_match(self, match_id: str, message: str) -> None: disconnected: list[WebSocket] = [] for ws in list(self.match_rooms.get(match_id, set())): try: await ws.send_text(message) except Exception: disconnected.append(ws) for ws in disconnected: await self.disconnect(ws, match_id) async def broadcast_to_all(self, message: str) -> None: disconnected: list[WebSocket] = [] for ws in list(self.all_connections): try: await ws.send_text(message) except Exception: disconnected.append(ws) for ws in disconnected: for match_id, sockets in list(self.match_rooms.items()): if ws in sockets: await self.disconnect(ws, match_id) app = FastAPI(title='2026 FIFA Real-Time Bus', version='1.0.0') manager = ConnectionManager() ML_MODELS: dict[str, EnsembleModelArtifact] = {'default': build_default_ensemble_artifact()} PROOF_OF_YIELD_STORE = ProofOfYieldStore() REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') WS_REDIS_CHANNELS = ('channel:live_odds', 'channel:match_events') class PlayerPropsRequest(BaseModel): player_id: str = Field(..., min_length=1, description='球員唯一識別碼') player_name: str | None = None metric: PropMetric baseline_mean: float = Field(..., gt=0) line: float = Field(..., gt=0) match_minutes: int = Field(default=90, ge=1, le=130) team_attack_factor: float = Field(default=1.0, ge=0.2, le=3.0) opponent_defence_factor: float = Field(default=1.0, ge=0.2, le=3.0) weather_fatigue_factor: float = Field(default=1.0, ge=0.2, le=2.0) bookmaker_over_odds: float | None = Field(default=None, gt=1) simulations: int = Field(default=10000, ge=100, le=200000) class PlayerPropsResponse(BaseModel): metric: str line: float over_probability: float under_probability: float expected_count: float p5: float p50: float p95: float simulation_runs: int edge: float | None = None top_edge: bool = False bookmaker_over_odds: float | None = None implied_prob: float | None = None class KellyRequest(BaseModel): odds: float = Field(..., gt=1) true_prob: float = Field(..., ge=0, le=1) bankroll: float = Field(..., gt=0) fractional_kelly_factor: float = Field(default=1.0, ge=0, le=5) risk_tolerance_factor: float = Field(default=1.0, ge=0, le=2) class KellyResponse(BaseModel): odds: float true_prob: float bankroll: float raw_kelly_fraction: float fractional_kelly_factor: float risk_tolerance_factor: float recommended_fraction: float recommended_stake: float class BacktestTrade(BaseModel): trade_id: str = Field(..., min_length=1) settled_at: datetime odds: float = Field(..., gt=1) is_win: bool stake: float = Field(default=100.0, gt=0) altitude_meters: int | None = None handicap: float | None = None weather: str | None = None recent_form_win_rate: float | None = Field(default=None, ge=0, le=1) market_type: str = '1x2' selection: str = 'home' class BacktestFilter(BaseModel): weather: str | None = None altitude_min_meters: int | None = None altitude_max_meters: int | None = None handicap_min: float | None = None handicap_max: float | None = None recent_win_rate_min: float | None = Field(default=None, ge=0, le=1) recent_win_rate_max: float | None = Field(default=None, ge=0, le=1) market_types: list[str] | None = None start_at: datetime | None = None end_at: datetime | None = None class BacktestRequest(BaseModel): initial_capital: float = Field(default=10000, gt=0) strategy: BacktestFilter = Field(default_factory=BacktestFilter) historical_trades: list[BacktestTrade] class BacktestPoint(BaseModel): ts: str capital: float class BacktestResponse(BaseModel): matched: int total: int hit_count: int win_rate: float final_capital: float net_profit: float roi_percent: float max_drawdown_percent: float equity_curve: list[BacktestPoint] class MlTrainingRow(BaseModel): home_rest_days: float = Field(..., description='主隊休息天數') away_rest_days: float = Field(..., description='客隊休息天數') home_travel_distance_km: float = Field(..., description='主隊旅行距離(km)') away_travel_distance_km: float = Field(..., description='客隊旅行距離(km)') recent_5_xg_home: float = Field(..., description='主隊近5場 xG') recent_5_xg_away: float = Field(..., description='客隊近5場 xG') match_result: str = Field(..., description='home / draw / away') class MlTrainRequest(BaseModel): model_id: str | None = None rows: list[MlTrainingRow] class MlTrainResponse(BaseModel): model_id: str status: str training_size: int is_fallback: bool accuracy: float | None class MlEdgeRequest(BaseModel): model_id: str | None = 'default' match_id: str = Field(...) home_rest_days: float away_rest_days: float home_travel_distance_km: float away_travel_distance_km: float recent_5_xg_home: float recent_5_xg_away: float home_implied_odds: float = Field(..., gt=1) draw_implied_odds: float = Field(..., gt=1) away_implied_odds: float = Field(..., gt=1) class MlModelEdge(BaseModel): model_prob: float implied_prob: float edge: float strong_buy: bool class MlEdgeResponse(BaseModel): match_id: str model_id: str is_fallback_model: bool model_probs: dict[str, float] edges: dict[str, MlModelEdge] strong_buy: bool strongest_outcome: str strongest_edge_percent: float feature_columns: list[str] training_size: int class MatchConditionRequest(BaseModel): match_id: str = Field(...) avg_yellow_cards: float penalties_per_game: float cards_ou_line: float temp_c: float humidity_pct: float venue_altitude_meters: int = Field(..., ge=0) home_second_half_attack: float = Field(..., gt=0) away_second_half_attack: float = Field(..., gt=0) class MatchConditionResponse(BaseModel): match_id: str strictness_index: float heat_index: float cards_pressure_alert: bool second_half_home_attack: float second_half_away_attack: float second_half_under_recommendation: bool attacker_direction: str class RlmRequest(BaseModel): match_id: str = Field(...) market_type: str = '1x2' selection: str = 'home' ticket_threshold: float = Field(default=80, ge=0, le=100) odds_change_threshold: float = Field(default=0.05, ge=0, le=1) class RlmAlertResponse(BaseModel): match_id: str market_type: str selection: str opening_odds: float current_odds: float ticket_pct: float handle_pct: float odds_change_pct: float smart_money_to: str is_triggered: bool rationale: str class RlmResponse(BaseModel): alerts: list[RlmAlertResponse] total: int class ProofOfYieldSettleItem(BaseModel): recommendation_id: str | None = None match_id: str market_type: str = '1x2' selection: str = 'home' stake: float = Field(..., gt=0) recommended_odds: float = Field(..., gt=1) closing_odds: float = Field(..., gt=0) is_win: bool settled_at: str | None = None class ProofOfYieldSettleRequest(BaseModel): items: list[ProofOfYieldSettleItem] class ProofOfYieldRecordResponse(BaseModel): recommendation_id: str match_id: str market_type: str selection: str stake: float recommended_odds: float closing_odds: float is_win: bool settled_at: str clv_percent: float | None pnl: float class ProofOfYieldSummaryResponse(BaseModel): total_recommendations: int hit_count: int win_rate_percent: float total_stake: float total_pnl: float roi_percent: float avg_clv_percent: float class ProofOfYieldLedgerResponse(BaseModel): summary: ProofOfYieldSummaryResponse records: list[ProofOfYieldRecordResponse] class UserBet(BaseModel): market_type: str = Field(default='unknown', min_length=1) parlay_type: str | None = None odds: float | None = Field(default=None, gt=1) stake: float = Field(..., gt=0) recommended_odds: float | None = Field(default=None, gt=1) closing_odds: float | None = Field(default=None, gt=0) is_settled: bool = True is_win: bool = False match_stage: str | None = None stage: str | None = None class PortfolioLeaksRequest(BaseModel): user_bets: list[UserBet] class PortfolioLeakCluster(BaseModel): market_type: str bet_type: str odds_bucket: str match_stage: str bet_count: int total_stake: float closed_count: int win_count: int total_pnl: float avg_clv_percent: float roi_percent: float hit_rate_percent: float status: str class PortfolioHardTruth(BaseModel): title: str message: str cluster: dict[str, str | int | float] class PortfolioLeaksResponse(BaseModel): total_bet_count: int settled_bet_count: int total_stake: float total_pnl: float overall_roi_percent: float overall_hit_rate_percent: float clusters: list[PortfolioLeakCluster] hard_truths: list[PortfolioHardTruth] class HedgeRequest(BaseModel): original_stake: float = Field(..., gt=0) parlay_total_odds: float = Field(..., gt=1) final_leg_hedge_odds: float = Field(..., gt=1) class HedgeResponse(BaseModel): hedge_stake: float locked_profit: float parlay_net_after_hedge_if_win: float hedge_net_if_win: float class DailyCardLeg(BaseModel): match_id: str selection: str odds: float = Field(..., gt=1) class DailyCardItem(BaseModel): match_id: str match_label: str market_type: str selection: str target_odds: float = Field(..., gt=1) win_prob: float ev_percent: float stake_units: float = Field(..., ge=0) recommendation: str rationale: str legs: list[DailyCardLeg] | None = None class DailyCardResponse(BaseModel): date: str total_daily_unit_recommendation: float summary: str safe_singles: list[DailyCardItem] high_risk_singles: list[DailyCardItem] safe_parlays: list[DailyCardItem] sgp_lotteries: list[DailyCardItem] matched_matches: int stage_distribution: dict[str, int] class MatchListItem(BaseModel): match_id: str home_team: str away_team: str kickoff_utc: datetime status: str venue_name: str | None = None venue_city: str | None = None venue_country: str | None = None class MatchOddsPoint(BaseModel): recorded_at: str bookmaker: str bookmaker_id: str market_type: str selection: str decimal_odds: float implied_probability: float class MatchPoissonOutput(BaseModel): expected_home_goals: float expected_away_goals: float score_matrix: list[list[float]] one_x_two: dict[str, float] over_under_2_5: dict[str, float] class MatchConditionReadout(BaseModel): strictness_index: float heat_index: float cards_pressure_alert: bool second_half_home_attack: float second_half_away_attack: float second_half_under_recommendation: bool attacker_direction: str class MatchDetailResponse(BaseModel): match_id: str home_team: str away_team: str home_xg: float away_xg: float match_time_utc: str status: str venue_name: str venue_city: str venue_country: str venue_altitude_meters: int | None odds_series: list[MatchOddsPoint] poisson: MatchPoissonOutput conditions: MatchConditionReadout quant_summary: str @app.get('/health') async def health() -> dict[str, str]: return { 'ok': 'true', 'service': 'fifa2026-websocket', } def _odds_to_prob(odds: float) -> float: return 1.0 / odds def _build_model_row_payload(req: MlEdgeRequest) -> dict[str, float]: return { 'match_id': req.match_id, 'home_rest_days': req.home_rest_days, 'away_rest_days': req.away_rest_days, 'home_travel_distance_km': req.home_travel_distance_km, 'away_travel_distance_km': req.away_travel_distance_km, 'recent_5_xg_home': req.recent_5_xg_home, 'recent_5_xg_away': req.recent_5_xg_away, } def _to_date(value: str) -> datetime.date: try: return datetime.strptime(value, '%Y-%m-%d').date() except ValueError as exc: raise HTTPException(status_code=400, detail='日期格式必須為 YYYY-MM-DD') from exc def _safe_float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _safe_int(value: Any, default: int | None = None) -> int | None: try: return int(value) except (TypeError, ValueError): return default def _build_quant_summary( home_team: str, away_team: str, home_xg: float, away_xg: float, one_x_two: dict[str, float], over_under: dict[str, float], ) -> str: """用 Poisson 核心輸出構造 300 字上下的賽前量化摘要。""" home_win = _safe_float(one_x_two.get('home_win')) draw = _safe_float(one_x_two.get('draw')) away_win = _safe_float(one_x_two.get('away_win')) under = _safe_float(over_under.get('under')) over = _safe_float(over_under.get('over')) return ( f'【資料驅動預測摘要】{home_team} vs {away_team} 的 xG 組合顯示,{home_team} 預估主場進球 {home_xg:.2f},\ {away_team} 進球 {away_xg:.2f},主勝機率約 {home_win:.1%}、平局 {draw:.1%}、客勝 {away_win:.1%}。\ 依 2.5 球門檻,走 Under 機率約 {under:.1%},Over 機率約 {over:.1%},\ 若模型判斷到賽中節奏偏中後段上升,建議關注亞盤與大小球組合與 Sharp Money 的偏移。\ 該筆預測建議以「盤口變動」與「賠率修正」為主軸,避免盲追單一 1X2 方向。\ 實盤可將本場解讀為偏向策略化下注:若盤口未按預估修正,應提高風險控管,等待第二次確認訊號。' ) async def _query_match_list(limit: int = 200) -> list[dict[str, Any]]: home_team = aliased(Team) away_team = aliased(Team) async with SessionFactory() as session: stmt = ( select( Match.id, home_team.name, away_team.name, Match.match_time_utc, Match.status, Venue.name, Venue.city, Venue.country, ) .join(home_team, Match.home_team_id == home_team.id) .join(away_team, Match.away_team_id == away_team.id) .join(Venue, Match.venue_id == Venue.id) .order_by(Match.match_time_utc.asc()) .limit(limit) ) result = await session.execute(stmt) rows = result.all() return [ { 'match_id': match_id, 'home_team': home_name, 'away_team': away_name, 'kickoff_utc': kickoff_utc, 'status': str(status.value if hasattr(status, 'value') else status), 'venue_name': venue_name, 'venue_city': venue_city, 'venue_country': venue_country, } for match_id, home_name, away_name, kickoff_utc, status, venue_name, venue_city, venue_country in rows ] def _build_over_under_payload(under: float, over: float) -> dict[str, float]: return { 'under': max(0.0, min(1.0, _safe_float(under))), 'over': max(0.0, min(1.0, _safe_float(over))), } async def _query_match_preview(match_id: str) -> dict[str, Any] | None: home_team = aliased(Team) away_team = aliased(Team) async with SessionFactory() as session: stmt = ( select(Match, home_team, away_team, Venue) .join(home_team, Match.home_team_id == home_team.id) .join(away_team, Match.away_team_id == away_team.id) .join(Venue, Match.venue_id == Venue.id) .where(Match.id == match_id) ) result = await session.execute(stmt) row = result.first() if not row: return None match = row[0] home = row[1] away = row[2] venue = row[3] home_xg = _safe_float(match.home_xg, 1.1) away_xg = _safe_float(match.away_xg, 1.0) odds_stmt = ( select( OddsHistory.recorded_at, OddsHistory.market_type, OddsHistory.selection, OddsHistory.decimal_odds, OddsHistory.implied_probability, Bookmaker.id, Bookmaker.name, ) .join(Bookmaker, OddsHistory.bookmaker_id == Bookmaker.id) .where(OddsHistory.match_id == match_id) .order_by(OddsHistory.recorded_at.asc(), OddsHistory.id.asc()) ) odds_result = await session.execute(odds_stmt) odds_rows = odds_result.all() odds_points: list[MatchOddsPoint] = [ MatchOddsPoint( recorded_at=point_recorded.strftime('%Y-%m-%dT%H:%M:%SZ') if hasattr(point_recorded, 'strftime') else str(point_recorded), bookmaker=str(name), bookmaker_id=str(bookmaker_id), market_type=market_type, selection=selection, decimal_odds=float(odds), implied_probability=float(implied_prob), ) for point_recorded, market_type, selection, odds, implied_prob, bookmaker_id, name in odds_rows ] if len(odds_points) == 0: fallback_now = datetime.utcnow().isoformat() + 'Z' odds_points = [ MatchOddsPoint( recorded_at=fallback_now, bookmaker='sample-bookmaker', bookmaker_id='sample', market_type='1x2', selection='home', decimal_odds=round(1.90 + (_safe_float(1.0) * 0.0), 2), implied_probability=0.52, ), ] predictor = PoissonMatchPredictor( home_attack_strength=max(0.35, home_xg), home_defense_strength=max(0.35, 1.0 + ((venue.altitude_meters or 0) / 5000.0)), away_attack_strength=max(0.35, away_xg), away_defense_strength=max(0.35, 1.0), league_avg_home_goals=1.35, ) expected_home_xg, expected_away_xg = predictor.calculate_expected_goals() score_matrix = predictor.predict_exact_score_matrix(max_goals=5).tolist() one_x_two = predictor.predict_1x2_probabilities() under_prob, over_prob = predictor.predict_over_under_prob(2.5) conditions = evaluate_match_conditions( avg_yellow_cards=4.0 + (1 if (venue.altitude_meters or 0) > 1000 else 0), penalties_per_game=0.18, cards_ou_line=4.5, temp_c=28.0 + ((venue.altitude_meters or 0) / 3000.0), humidity_pct=52.0 + min(20.0, (venue.altitude_meters or 0) / 150.0 * 0.1), venue_altitude_meters=venue.altitude_meters or 0, home_second_half_attack=expected_home_xg * 1.08, away_second_half_attack=expected_away_xg * 1.02, ) quant_summary = _build_quant_summary( home_team=home.name, away_team=away.name, home_xg=expected_home_xg, away_xg=expected_away_xg, one_x_two=one_x_two, over_under={'under': under_prob, 'over': over_prob}, ) return { 'match_id': match.id, 'home_team': home.name, 'away_team': away.name, 'home_xg': expected_home_xg, 'away_xg': expected_away_xg, 'match_time_utc': (match.match_time_utc.isoformat() if match.match_time_utc else datetime.utcnow().isoformat()), 'status': str(match.status.value if hasattr(match.status, 'value') else match.status), 'venue_name': venue.name, 'venue_city': venue.city, 'venue_country': venue.country, 'venue_altitude_meters': venue.altitude_meters, 'odds_series': odds_points, 'poisson': { 'expected_home_goals': expected_home_xg, 'expected_away_goals': expected_away_xg, 'score_matrix': score_matrix, 'one_x_two': one_x_two, 'over_under_2_5': _build_over_under_payload(under_prob, over_prob), }, 'conditions': { 'strictness_index': conditions.strictness_index, 'heat_index': conditions.heat_index, 'cards_pressure_alert': conditions.cards_pressure_alert, 'second_half_home_attack': conditions.second_half_home_attack, 'second_half_away_attack': conditions.second_half_away_attack, 'second_half_under_recommendation': conditions.second_half_under_recommendation, 'attacker_direction': conditions.attacker_direction, }, 'quant_summary': quant_summary, } def _build_daily_card_fallback(matches_on_date: list[dict[str, Any]]) -> list[dict[str, Any]]: if matches_on_date: return matches_on_date return [ { 'match_id': 'm1', 'home_team': '德國', 'away_team': '西班牙', 'odds_home': 1.92, 'odds_away': 4.05, 'home_xg': 1.45, 'away_xg': 0.95, }, { 'match_id': 'm2', 'home_team': '巴西', 'away_team': '法國', 'odds_home': 2.05, 'odds_away': 3.6, 'home_xg': 1.32, 'away_xg': 1.25, }, ] async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str, Any]]: home_team = aliased(Team) away_team = aliased(Team) async with SessionFactory() as session: stmt = ( select( Match.id, Match.home_xg, Match.away_xg, Match.match_time_utc, home_team.name, away_team.name, ) .join(home_team, Match.home_team_id == home_team.id) .join(away_team, Match.away_team_id == away_team.id) .where(func.date(Match.match_time_utc) == target_date) .order_by(Match.match_time_utc.asc()) ) result = await session.execute(stmt) rows = result.all() matches_payload: list[dict[str, Any]] = [] for row in rows: match_id, home_xg, away_xg, match_time_utc, home_name, away_name = row opening_home, current_home = await _query_opening_odds(session, match_id, '1x2', 'home') opening_away, current_away = await _query_opening_odds(session, match_id, '1x2', 'away') current_home = current_home if current_home is not None else opening_home current_away = current_away if current_away is not None else opening_away if current_home is not None and current_away is not None: matches_payload.append( { 'match_id': match_id, 'home_team': home_name, 'away_team': away_name, 'home_xg': float(home_xg or 1.0), 'away_xg': float(away_xg or 1.0), 'odds_home': float(current_home), 'odds_away': float(current_away), 'kickoff_utc': match_time_utc, }, ) return _build_daily_card_fallback(matches_payload) async def _query_latest_smart_money( session: Any, match_id: str, market_type: str, selection: str, ) -> Any: latest_stmt = ( select(SmartMoneyFlow) .where( SmartMoneyFlow.match_id == match_id, SmartMoneyFlow.market_type == market_type, SmartMoneyFlow.selection == selection, ) .order_by(desc(SmartMoneyFlow.recorded_at)) .limit(1) ) latest = await session.execute(latest_stmt) return latest.scalar_one_or_none() def _safe_float(value: Any, *, default: float | None = None) -> float | None: try: return float(value) except (TypeError, ValueError): return default async def _query_latest_smart_money_from_cache( redis_conn: Redis, match_id: str, market_type: str, selection: str, ) -> SmartMoneyFlow | None: cache_keys = ( f'smart_money:{match_id}:{market_type}:{selection}', f'smart-money:{match_id}:{market_type}:{selection}', f'match:{match_id}:{market_type}:{selection}:smart_money', ) for key in cache_keys: raw = await redis_conn.get(key) if not raw: continue try: parsed = json.loads(raw.decode() if isinstance(raw, bytes) else raw) except (TypeError, json.JSONDecodeError): continue if not isinstance(parsed, Mapping): continue ticket_pct = _safe_float(parsed.get('ticket_pct')) handle_pct = _safe_float(parsed.get('handle_pct')) if ticket_pct is None or handle_pct is None: continue return SmartMoneyFlow( id=0, match_id=match_id, market_type=market_type, selection=selection, ticket_pct=ticket_pct, handle_pct=handle_pct, sharp_indicator=bool(parsed.get('sharp_indicator', False)), recorded_at=datetime.utcnow(), ) return None async def _query_opening_current_odds_from_cache( redis_conn: Redis, match_id: str, market_type: str, selection: str, ) -> tuple[float | None, float | None]: opening_key = f'odds_open:{match_id}:{market_type}:{selection}' current_key = f'odds_current:{match_id}:{market_type}:{selection}' opening_raw = await redis_conn.get(opening_key) current_raw = await redis_conn.get(current_key) opening_odds = _safe_float( opening_raw.decode() if isinstance(opening_raw, bytes) else opening_raw, default=None, ) current_odds = _safe_float( current_raw.decode() if isinstance(current_raw, bytes) else current_raw, default=None, ) if opening_odds is not None and current_odds is not None: return opening_odds, current_odds # fallback: decode live snapshot 列表 live_raw = await redis_conn.get(f'live:{match_id}:odds') if not live_raw: return None, None try: live_payload = json.loads(live_raw.decode() if isinstance(live_raw, bytes) else live_raw) except (TypeError, json.JSONDecodeError): return None, None if not isinstance(live_payload, list): return None, None filtered = [ row for row in live_payload if isinstance(row, Mapping) and str(row.get('match_id', match_id)) == str(match_id) and str(row.get('market_type')) == str(market_type) and str(row.get('selection')) == str(selection) and _safe_float(row.get('decimal_odds'), default=None) is not None ] if not filtered: return None, None values = [ (_safe_float(row.get('recorded_at'), default=None), _safe_float(row.get('decimal_odds'), default=None)) for row in filtered ] values = [item for item in values if item[1] is not None] if not values: return None, None values.sort(key=lambda item: item[0] if item[0] is not None else 0) opening = float(values[0][1]) current = float(values[-1][1]) return opening, current async def _query_opening_odds( session: Any, match_id: str, market_type: str, selection: str, ) -> tuple[float | None, float | None]: opening_stmt = ( select(OddsHistory.decimal_odds) .join(Match, Match.id == OddsHistory.match_id) .where( OddsHistory.match_id == match_id, OddsHistory.market_type == market_type, OddsHistory.selection == selection, ) .order_by(asc(OddsHistory.recorded_at)) .limit(1) ) current_stmt = ( select(OddsHistory.decimal_odds) .where( OddsHistory.match_id == match_id, OddsHistory.market_type == market_type, OddsHistory.selection == selection, ) .order_by(desc(OddsHistory.recorded_at)) .limit(1) ) opening_result = await session.execute(opening_stmt) current_result = await session.execute(current_stmt) opening_row = opening_result.scalar_one_or_none() current_row = current_result.scalar_one_or_none() return ( float(opening_row) if opening_row is not None else None, float(current_row) if current_row is not None else None, ) def _to_summary(records: list[ProofYieldRecord]) -> LedgerSummary: return ProofOfYieldStore.summarize(records) def _to_ledger_response(records: list[ProofYieldRecord]) -> list[ProofOfYieldRecordResponse]: return [ ProofOfYieldRecordResponse( recommendation_id=row.recommendation_id, match_id=row.match_id, market_type=row.market_type, selection=row.selection, stake=row.stake, recommended_odds=row.recommended_odds, closing_odds=row.closing_odds or 0.0, is_win=row.is_win, settled_at=row.settled_at, clv_percent=row.clv_percent, pnl=row.pnl, ) for row in records ] async def relay_redis_events() -> None: redis = Redis.from_url(REDIS_URL, decode_responses=True) pubsub = redis.pubsub() await pubsub.subscribe(*WS_REDIS_CHANNELS) logger.info('Redis Pub/Sub 已啟動,監聽 %s', ','.join(WS_REDIS_CHANNELS)) try: async for message in pubsub.listen(): if message['type'] != 'message': continue raw = message['data'] payload = raw if isinstance(raw, str) else str(raw) try: parsed = json.loads(payload) except ValueError: channel = message['channel'] if isinstance(channel, bytes): channel = channel.decode('utf-8', errors='ignore') parsed = {'eventType': channel, 'payload': payload} match_id = ( parsed.get('match_id') or parsed.get('matchId') or parsed.get('payload', {}).get('match_id') or parsed.get('payload', {}).get('matchId') or '' ) message_str = json.dumps(parsed, ensure_ascii=False) if match_id: await manager.broadcast_to_match(str(match_id), message_str) else: await manager.broadcast_to_all(message_str) except asyncio.CancelledError: raise except Exception as exc: logger.error('Redis listener error: %s', exc) finally: await pubsub.unsubscribe(*WS_REDIS_CHANNELS) await pubsub.close() await redis.close() @app.on_event('startup') async def on_startup() -> None: app.state.redis_listener = asyncio.create_task(relay_redis_events()) @app.on_event('shutdown') async def on_shutdown() -> None: listener = getattr(app.state, 'redis_listener', None) if listener: listener.cancel() with contextlib.suppress(asyncio.CancelledError): await listener @app.websocket('/ws/matches/{match_id}') async def websocket_endpoint(websocket: WebSocket, match_id: str) -> None: await manager.connect(websocket, match_id) try: await websocket.send_text(json.dumps({'eventType': 'connected', 'matchId': match_id, 'payload': {}})) while True: message = await websocket.receive_text() try: data = json.loads(message) except ValueError: await websocket.send_text(json.dumps({'eventType': 'error', 'payload': {'message': 'JSON 格式錯誤'}})) continue if data.get('action') == 'ping': await websocket.send_text(json.dumps({'eventType': 'pong', 'payload': {'matchId': match_id}})) except WebSocketDisconnect: await manager.disconnect(websocket, match_id) @app.post('/analytics/player-props', response_model=PlayerPropsResponse) async def analyze_player_prop(req: PlayerPropsRequest) -> dict[str, float | int | str | bool | None]: profile = PlayerPropsProfile( player_id=req.player_id, metric=req.metric, baseline_mean=req.baseline_mean, match_minutes=req.match_minutes, team_attack_factor=req.team_attack_factor, opponent_defence_factor=req.opponent_defence_factor, weather_fatigue_factor=req.weather_fatigue_factor, ) if req.bookmaker_over_odds is None: result = simulate_player_prop_probability( profile, line=req.line, simulations=req.simulations, ) response = result.to_dict() response.update({'edge': None, 'top_edge': False, 'bookmaker_over_odds': None, 'implied_prob': None}) return response return evaluate_top_edge( profile, bookmaker_over_odds=req.bookmaker_over_odds, line=req.line, simulations=req.simulations, ) @app.post('/analytics/kelly', response_model=KellyResponse) async def calculate_kelly(request: KellyRequest) -> KellyResponse: result: KellyResult = calculate_kelly_fraction( request.odds, request.true_prob, bankroll=request.bankroll, fractional_kelly_factor=request.fractional_kelly_factor, risk_tolerance_factor=request.risk_tolerance_factor, ) return KellyResponse( odds=result.decimal_odds, true_prob=result.win_probability, bankroll=request.bankroll, raw_kelly_fraction=round(result.raw_kelly_fraction, 6), fractional_kelly_factor=result.fractional_kelly_factor, risk_tolerance_factor=result.risk_tolerance_factor, recommended_fraction=round(result.stake_fraction * 100, 6), recommended_stake=round(request.bankroll * result.stake_fraction, 2), ) @app.post('/analytics/backtest', response_model=BacktestResponse) async def run_backtest(req: BacktestRequest) -> BacktestResponse: strategy_filter = StrategyFilter( weather=req.strategy.weather, altitude_min_meters=req.strategy.altitude_min_meters, altitude_max_meters=req.strategy.altitude_max_meters, handicap_min=req.strategy.handicap_min, handicap_max=req.strategy.handicap_max, recent_win_rate_min=req.strategy.recent_win_rate_min, recent_win_rate_max=req.strategy.recent_win_rate_max, market_types=req.strategy.market_types, start_at=req.strategy.start_at, end_at=req.strategy.end_at, ) try: records = [ BacktestTradeRecord( trade_id=item.trade_id, settled_at=item.settled_at, odds=item.odds, is_win=item.is_win, stake=item.stake, altitude_meters=item.altitude_meters, handicap=item.handicap, weather=item.weather, recent_form_win_rate=item.recent_form_win_rate, market_type=item.market_type, selection=item.selection, ) for item in req.historical_trades ] except Exception as exc: raise HTTPException(status_code=400, detail=f'交易資料欄位不合法:{exc}') from exc filtered = filter_trades(records, strategy_filter) if not filtered: raise HTTPException( status_code=404, detail='無法符合條件的歷史資料;請放寬條件或增加歷史注單輸入', ) summary = run_flat_stake_backtest(filtered, initial_capital=req.initial_capital) return BacktestResponse( matched=summary['trade_count'], total=len(records), hit_count=summary['hit_count'], win_rate=summary['win_rate'], final_capital=summary['final_capital'], net_profit=summary['net_profit'], roi_percent=summary['roi_percent'], max_drawdown_percent=summary['max_drawdown_percent'], equity_curve=[BacktestPoint(**point) for point in summary['equity_curve']], ) @app.post('/analytics/ml-edge/train', response_model=MlTrainResponse) async def train_ml_model(req: MlTrainRequest) -> MlTrainResponse: if not req.rows: raise HTTPException(status_code=400, detail='訓練資料不能為空') prepared_rows = [ { **normalize_feature_payload(row.model_dump()), 'match_result': row.match_result.lower(), } for row in req.rows ] model_id = req.model_id or uuid4().hex try: model = train_match_outcome_ensemble(prepared_rows, model_id=model_id) except Exception as exc: raise HTTPException(status_code=400, detail=f'訓練失敗:{exc}') from exc ML_MODELS[model_id] = model return MlTrainResponse( model_id=model_id, status='trained', training_size=model.training_size, is_fallback=model.is_fallback, accuracy=model.training_accuracy, ) @app.post('/analytics/ml-edge', response_model=MlEdgeResponse) async def predict_ml_edge(req: MlEdgeRequest) -> MlEdgeResponse: model = ML_MODELS.get(req.model_id or 'default') if model is None: raise HTTPException(status_code=404, detail='模型不存在,請先透過 /analytics/ml-edge/train 取得模型') payload = _build_model_row_payload(req) try: model_probs = model_predict_probabilities(model, payload) except Exception as exc: raise HTTPException(status_code=500, detail=f'模型推論失敗:{exc}') from exc implied = { 'home': _odds_to_prob(req.home_implied_odds), 'draw': _odds_to_prob(req.draw_implied_odds), 'away': _odds_to_prob(req.away_implied_odds), } edge_map = calculate_model_edges(model_probs, implied) strong_edges = [outcome for outcome, values in edge_map.items() if bool(values['strong_buy'])] strongest_outcome = max(edge_map, key=lambda outcome: edge_map[outcome]['edge']) strongest_edge = edge_map[strongest_outcome]['edge'] return MlEdgeResponse( match_id=req.match_id, model_id=model.model_id, is_fallback_model=model.is_fallback, model_probs={k: float(v['model_prob']) for k, v in edge_map.items()}, edges={k: MlModelEdge(**v) for k, v in edge_map.items()}, # type: ignore[arg-type] strong_buy=len(strong_edges) > 0, strongest_outcome=strongest_outcome, strongest_edge_percent=round(strongest_edge * 100, 4), feature_columns=list(model.feature_columns), training_size=model.training_size, ) @app.post('/analytics/match-conditions', response_model=MatchConditionResponse) async def analyze_match_conditions(req: MatchConditionRequest) -> MatchConditionResponse: result = evaluate_match_conditions( avg_yellow_cards=req.avg_yellow_cards, penalties_per_game=req.penalties_per_game, cards_ou_line=req.cards_ou_line, temp_c=req.temp_c, humidity_pct=req.humidity_pct, venue_altitude_meters=req.venue_altitude_meters, home_second_half_attack=req.home_second_half_attack, away_second_half_attack=req.away_second_half_attack, ) return MatchConditionResponse( match_id=req.match_id, strictness_index=result.strictness_index, heat_index=result.heat_index, cards_pressure_alert=result.cards_pressure_alert, second_half_home_attack=result.second_half_home_attack, second_half_away_attack=result.second_half_away_attack, second_half_under_recommendation=result.second_half_under_recommendation, attacker_direction=result.attacker_direction, ) @app.post('/analytics/rlm', response_model=RlmResponse) async def detect_reverse_line_movement(req: RlmRequest) -> RlmResponse: latest_money = None opening_odds: float | None = None current_odds: float | None = None async with SessionFactory() as session: try: latest_money = await _query_latest_smart_money(session, req.match_id, req.market_type, req.selection) opening_odds, current_odds = await _query_opening_odds(session, req.match_id, req.market_type, req.selection) except SQLAlchemyError as exc: logger.warning('RLM 查詢 DB 失敗,改用快取/預留資料:%s', exc) if (latest_money is None or opening_odds is None or current_odds is None): redis = Redis.from_url(REDIS_URL, decode_responses=False) try: if latest_money is None: latest_money = await _query_latest_smart_money_from_cache(redis, req.match_id, req.market_type, req.selection) if opening_odds is None or current_odds is None: opening_odds, current_odds = await _query_opening_current_odds_from_cache( redis, req.match_id, req.market_type, req.selection, ) except Exception as exc: logger.warning('RLM 查詢 Redis 失敗:%s', exc) finally: await redis.close() alerts: list[RlmAlertResponse] = [] if latest_money is not None and opening_odds is not None and current_odds is not None: alert = evaluate_reverse_line_movement( req.match_id, req.market_type, req.selection, opening_odds=opening_odds, current_odds=current_odds, ticket_pct=float(latest_money.ticket_pct), handle_pct=float(latest_money.handle_pct), ticket_threshold=req.ticket_threshold, odds_change_threshold=req.odds_change_threshold, ) alerts.append( RlmAlertResponse( match_id=alert.match_id, market_type=alert.market_type, selection=alert.selection, opening_odds=alert.opening_odds, current_odds=alert.current_odds, ticket_pct=alert.ticket_pct, handle_pct=alert.handle_pct, odds_change_pct=alert.odds_change_pct, smart_money_to=alert.smart_money_to, is_triggered=alert.is_triggered, rationale=alert.rationale, ), ) return RlmResponse(alerts=alerts, total=len(alerts)) @app.get('/analytics/proof-of-yield/ledger', response_model=ProofOfYieldLedgerResponse) async def get_proof_of_yield_ledger(limit: int = 200) -> ProofOfYieldLedgerResponse: records = PROOF_OF_YIELD_STORE.query_ledger(limit=max(1, min(limit, 1000))) summary = _to_summary(records) return ProofOfYieldLedgerResponse( summary=ProofOfYieldSummaryResponse(**summary.__dict__), records=_to_ledger_response(records), ) @app.post('/analytics/proof-of-yield/settle', response_model=ProofOfYieldLedgerResponse) async def settle_proof_of_yield_recommendations( req: ProofOfYieldSettleRequest, ) -> ProofOfYieldLedgerResponse: if not req.items: raise HTTPException(status_code=400, detail='請至少提供一筆建議明細') PROOF_OF_YIELD_STORE.upsert_settlements([item.model_dump() for item in req.items]) records = PROOF_OF_YIELD_STORE.query_ledger(limit=1000) summary = _to_summary(records) return ProofOfYieldLedgerResponse( summary=ProofOfYieldSummaryResponse(**summary.__dict__), records=_to_ledger_response(records), ) @app.get('/analytics/matches', response_model=list[MatchListItem]) async def list_matches(limit: int = 500) -> list[MatchListItem]: rows = await _query_match_list(limit=max(1, min(limit, 1000))) if not rows: return [] return [ MatchListItem( match_id=match_payload['match_id'], home_team=match_payload['home_team'], away_team=match_payload['away_team'], kickoff_utc=match_payload['kickoff_utc'], status=str(match_payload['status']), venue_name=match_payload['venue_name'], venue_city=match_payload['venue_city'], venue_country=match_payload['venue_country'], ) for match_payload in rows ] @app.get('/analytics/matches/{match_id}', response_model=MatchDetailResponse) async def get_match_detail_route(match_id: str) -> MatchDetailResponse: payload = await _query_match_preview(match_id) if payload is None: raise HTTPException(status_code=404, detail='賽事不存在') return MatchDetailResponse( match_id=payload['match_id'], home_team=payload['home_team'], away_team=payload['away_team'], home_xg=payload['home_xg'], away_xg=payload['away_xg'], match_time_utc=payload['match_time_utc'], status=payload['status'], venue_name=payload['venue_name'], venue_city=payload['venue_city'], venue_country=payload['venue_country'], venue_altitude_meters=payload['venue_altitude_meters'], odds_series=payload['odds_series'], poisson=payload['poisson'], conditions=payload['conditions'], quant_summary=payload['quant_summary'], ) @app.post('/analytics/portfolio/leaks', response_model=PortfolioLeaksResponse) async def analyze_portfolio_leaks(req: PortfolioLeaksRequest) -> PortfolioLeaksResponse: payload = [bet.model_dump() for bet in req.user_bets] result = analyze_user_leaks(payload) return PortfolioLeaksResponse( total_bet_count=result['total_bet_count'], settled_bet_count=result['settled_bet_count'], total_stake=result['total_stake'], total_pnl=result['total_pnl'], overall_roi_percent=result['overall_roi_percent'], overall_hit_rate_percent=result['overall_hit_rate_percent'], clusters=[PortfolioLeakCluster(**item) for item in result['clusters']], hard_truths=[PortfolioHardTruth(**item) for item in result['hard_truths']], ) @app.post('/analytics/hedging', response_model=HedgeResponse) async def calculate_hedge_signal(req: HedgeRequest) -> HedgeResponse: result = calculate_hedge_amount( original_stake=req.original_stake, parlay_total_odds=req.parlay_total_odds, final_leg_hedge_odds=req.final_leg_hedge_odds, ) return HedgeResponse(**result) @app.get('/analytics/daily-card/{target_date}', response_model=DailyCardResponse) async def generate_daily_card_route(target_date: str) -> DailyCardResponse: card_date = _to_date(target_date) match_payload = await _query_match_day_snapshot(card_date) result = generate_daily_card(target_date, match_payload) return DailyCardResponse(**result) if __name__ == '__main__': import uvicorn uvicorn.run('main:app', host='0.0.0.0', port=int(os.environ.get('PORT', '8000')))