import asyncio import json import logging import os import re import contextlib from collections import defaultdict from datetime import datetime, time, timedelta, timezone from typing import Any, Mapping from uuid import uuid4 import httpx 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, DailyRecommendationSnapshot, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue from .analytics.daily_card_generator import generate_daily_card from .analytics.localization import ( localize_city, localize_country, localize_market_type, localize_selection, localize_status, localize_team_name, localize_venue_name, ) 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) logging.getLogger('httpx').setLevel(logging.WARNING) logging.getLogger('httpcore').setLevel(logging.WARNING) 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') AGENT_DAILY_REVIEW_STATUS_KEY = 'agent:daily_review:last_run' AGENT_DAILY_REVIEW_CACHE_TTL_SECONDS = max(3600, int(os.environ.get('AGENT_REVIEW_CACHE_TTL_SECONDS', str(72 * 3600)))) DAILY_CARD_CALENDAR_STATUS_KEY = 'daily-card-calendar:last_run' DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS = max( 60, int(os.environ.get('DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS', '420')), ) TEAM_STRENGTH_PRIORS: dict[str, tuple[int, float]] = { 'argentina': (1, 1995), 'france': (2, 1985), 'spain': (3, 1950), 'england': (4, 1940), 'brazil': (5, 1935), 'portugal': (6, 1905), 'netherlands': (7, 1885), 'belgium': (8, 1875), 'germany': (9, 1865), 'italy': (10, 1845), 'uruguay': (11, 1830), 'croatia': (12, 1815), 'mexico': (13, 1795), 'usa': (14, 1785), 'united states': (14, 1785), 'usmnt': (14, 1785), 'colombia': (15, 1780), 'morocco': (16, 1775), 'switzerland': (17, 1765), 'japan': (18, 1760), 'senegal': (19, 1745), 'denmark': (20, 1740), 'iran': (21, 1725), 'sweden': (22, 1720), 'australia': (25, 1695), 'turkiye': (26, 1690), 'turkey': (26, 1690), 'ecuador': (27, 1685), "cote d'ivoire": (28, 1680), 'cote d’ivoire': (28, 1680), 'ivory coast': (28, 1680), 'tunisia': (34, 1625), 'curacao': (86, 1450), 'curaçao': (86, 1450), } 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) market_type: str | None = None has_market_odds: bool | None = None odds_source_label: str | None = None 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) stake_amount_twd: int | None = None unit_size_twd: int | None = None recommendation: str rationale: str confidence_score: float | None = None confidence_band: str | None = None confidence_factors: list[str] | None = None data_quality: str | None = None has_market_odds: bool | None = None odds_source_label: str | None = None odds_source_kind: str | None = None risk_level: str | None = None market_implied_prob: float | None = None edge_percent: float | None = None data_checks: list[str] | None = None legs: list[DailyCardLeg] | None = None sgp_price_status: str | None = None class DailyCardResponse(BaseModel): date: str total_daily_unit_recommendation: float total_daily_amount_twd: int | None = None unit_size_twd: int | None = None summary: str market_data_status: str | None = None data_quality_summary: dict[str, int] = Field(default_factory=dict) execution_policy: str | None = None auto_refresh_seconds: int = 60 safe_singles: list[DailyCardItem] high_risk_singles: list[DailyCardItem] safe_parlays: list[DailyCardItem] sgp_lotteries: list[DailyCardItem] matched_matches: int raw_match_count: int | None = None merged_duplicate_match_count: int | None = None dedupe_notes: list[str] | None = None stage_distribution: dict[str, int] class RecommendationPerformanceItem(BaseModel): match_id: str match_label: str market_type: str selection: str recommendation: str result_score: str outcome: str outcome_label: str target_odds: float win_prob: float ev_percent: float stake_units: float stake_amount_twd: int | None = None confidence_score: float | None = None confidence_band: str | None = None has_market_odds: bool | None = None odds_source_label: str | None = None odds_source_kind: str | None = None lesson: str class RecommendationPerformanceBucket(BaseModel): market_type: str recommendation_count: int settled_count: int hit_count: int miss_count: int push_count: int hit_rate_percent: float class RecommendationPerformanceSourceBucket(BaseModel): source_label: str source_kind: str recommendation_count: int settled_count: int hit_count: int miss_count: int push_count: int hit_rate_percent: float class RecommendationPerformanceResponse(BaseModel): generated_at: str days_back: int finished_match_count: int rebuilt_recommendation_count: int settled_recommendation_count: int hit_count: int miss_count: int push_count: int hit_rate_percent: float summary: str methodology_note: str improvement_actions: list[str] by_market_type: list[RecommendationPerformanceBucket] by_odds_source: list[RecommendationPerformanceSourceBucket] items: list[RecommendationPerformanceItem] class AgentVerificationCheck(BaseModel): agent: str role: str status: str status_label: str evidence: list[str] next_action: str | None = None last_checked_at: str class AgentVerificationResponse(BaseModel): generated_at: str overall_status: str overall_label: str production_ready: bool decision_policy: str calibration_summary: dict[str, Any] = Field(default_factory=dict) checks: list[AgentVerificationCheck] class GeminiUsageResponse(BaseModel): generated_at: str month: str status: str status_label: str paused: bool cap_usd: float estimated_cost_usd: float remaining_usd: float request_count: int input_tokens: int output_tokens: int grounded_query_count: int pricing_note: str next_action: str | None = None class AgentDailyReviewResponse(BaseModel): generated_at: str date: str status: str status_label: str model: str reviewed_count: int summary: str raw_response: str | None = None guardrails: list[str] class MatchListItem(BaseModel): match_id: str home_team: str away_team: str home_score: int | None = None away_score: int | None = None 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_score: int | None = None away_score: int | None = None 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] odds_quality: str = 'missing_market' xg_quality: str = 'observed' poisson: MatchPoissonOutput conditions: MatchConditionReadout quant_summary: str class SourceHealthResponse(BaseModel): status: str odds_coverage_status: str = 'unknown' upcoming_odds_matches: int = 0 stale_unsettled_matches: int = 0 stale_unsettled_threshold_hours: int = 3 odds_rows: int matches: int finished_matches: int venues: int high_altitude_venues: int latest_odds_recorded_at: str | None = None latest_result_synced_at: str | None = None ingestion_status: dict[str, Any] | None = None fixtures_status: dict[str, Any] | None = None news_status: dict[str, Any] | None = None provider_requirements: dict[str, Any] = Field(default_factory=dict) @app.get('/health') async def health() -> dict[str, str]: return { 'ok': 'true', 'service': 'fifa2026-websocket', } @app.get('/analytics/source-health', response_model=SourceHealthResponse) async def analytics_source_health() -> SourceHealthResponse: """回傳資料源新鮮度,讓前端能明確標示盤口是否仍在更新。""" now = datetime.now(timezone.utc) stale_threshold_hours = 3 async with SessionFactory() as session: match_count_result = await session.execute(select(func.count()).select_from(Match)) finished_count_result = await session.execute( select(func.count()).select_from(Match).where(Match.status == MatchStatus.FINISHED) ) odds_count_result = await session.execute(select(func.count()).select_from(OddsHistory)) venue_count_result = await session.execute(select(func.count()).select_from(Venue)) high_altitude_result = await session.execute( select(func.count()).select_from(Venue).where(Venue.altitude_meters >= 1500) ) latest_odds_result = await session.execute(select(func.max(OddsHistory.recorded_at))) latest_result_result = await session.execute(select(func.max(Match.result_synced_at))) stale_unsettled_result = await session.execute( select(func.count()) .select_from(Match) .where( Match.status != MatchStatus.FINISHED, Match.match_time_utc < now - timedelta(hours=stale_threshold_hours), ) ) upcoming_odds_result = await session.execute( select(func.count(func.distinct(OddsHistory.match_id))) .join(Match, OddsHistory.match_id == Match.id) .where( Match.match_time_utc >= now - timedelta(minutes=20), Match.status != MatchStatus.FINISHED, ) ) ingestion_status: dict[str, Any] | None = None fixtures_status: dict[str, Any] | None = None news_status: dict[str, Any] | None = None try: redis = Redis.from_url(REDIS_URL, decode_responses=True) raw_status = await redis.get('ingestion:odds:last_run') raw_fixtures_status = await redis.get('ingestion:fixtures:last_run') raw_news_status = await redis.get('ingestion:news:last_run') await redis.aclose() if raw_status: parsed = json.loads(raw_status) if isinstance(parsed, dict): ingestion_status = parsed if raw_fixtures_status: parsed_fixtures = json.loads(raw_fixtures_status) if isinstance(parsed_fixtures, dict): fixtures_status = parsed_fixtures if raw_news_status: parsed_news = json.loads(raw_news_status) if isinstance(parsed_news, dict): news_status = parsed_news except Exception as exc: ingestion_status = { 'status': 'unknown', 'message': f'Redis ingestion 狀態暫時無法讀取:{exc}', } latest_odds = latest_odds_result.scalar_one_or_none() odds_rows = int(odds_count_result.scalar_one() or 0) matches = int(match_count_result.scalar_one() or 0) finished_matches = int(finished_count_result.scalar_one() or 0) stale_unsettled_matches = int(stale_unsettled_result.scalar_one() or 0) try: logical_matches = await _query_match_list(limit=5000) if logical_matches: matches = len(logical_matches) finished_matches = sum(1 for item in logical_matches if item.get('status') == '已完賽') logical_stale = 0 for item in logical_matches: if item.get('status') == '已完賽': continue kickoff_raw = str(item.get('kickoff_utc') or '').replace('Z', '+00:00') try: kickoff_at = datetime.fromisoformat(kickoff_raw) except ValueError: continue if kickoff_at.tzinfo is None: kickoff_at = kickoff_at.replace(tzinfo=timezone.utc) if kickoff_at < now - timedelta(hours=stale_threshold_hours): logical_stale += 1 stale_unsettled_matches = logical_stale except Exception: pass venues = int(venue_count_result.scalar_one() or 0) high_altitude_venues = int(high_altitude_result.scalar_one() or 0) latest_result = latest_result_result.scalar_one_or_none() upcoming_odds_matches = int(upcoming_odds_result.scalar_one() or 0) worker_status = str((ingestion_status or {}).get('status') or 'unknown') source_name = str((ingestion_status or {}).get('source') or 'unknown') if odds_rows <= 0 or latest_odds is None or worker_status == 'error': odds_coverage_status = 'stale' elif 'taiwan-sports-lottery-reference' in source_name: odds_coverage_status = 'reference_market' elif source_name != 'the-odds-api': odds_coverage_status = 'limited_scoreboard_fallback' elif upcoming_odds_matches <= 0: odds_coverage_status = 'no_upcoming_market' else: odds_coverage_status = 'full_market' if stale_unsettled_matches > 0: freshness_status = 'stale' else: freshness_status = 'ok' if odds_coverage_status == 'full_market' else 'limited' if odds_coverage_status != 'stale' else 'stale' return SourceHealthResponse( status=freshness_status, odds_coverage_status=odds_coverage_status, upcoming_odds_matches=upcoming_odds_matches, stale_unsettled_matches=stale_unsettled_matches, stale_unsettled_threshold_hours=stale_threshold_hours, odds_rows=odds_rows, matches=matches, finished_matches=finished_matches, venues=venues, high_altitude_venues=high_altitude_venues, latest_odds_recorded_at=latest_odds.isoformat() if latest_odds else None, latest_result_synced_at=latest_result.isoformat() if latest_result else None, ingestion_status=ingestion_status, fixtures_status=fixtures_status, news_status=news_status, provider_requirements={ 'primary_odds_provider': 'THE_ODDS_API_KEY / The Odds API' if source_name != 'the-odds-api' else 'the-odds-api', 'odds_feed_markets': [ 'h2h', 'spreads', 'totals', 'btts', 'draw_no_bet', 'h2h_3_way', 'alternate_spreads', 'alternate_totals', 'team_totals', 'alternate_team_totals', ], 'derived_recommendation_markets': ['double_chance'], 'taiwan_sports_lottery': '已確認公開世界盃參考盤端點 Pre/WC-Games.zh.json;目前定位為台灣盤比對與最低可接受賠率參考,不等同多莊家正式 provider。', 'taiwan_sports_lottery_status': 'reference_market_enabled' if 'taiwan-sports-lottery-reference' in source_name else 'reference_market_waiting', 'current_limitation': '目前若未接入多莊家正式 odds provider,未來賽事只能產生台灣盤參考與預掛條件,不能包裝成保證高勝率。', }, ) @app.get('/analytics/market-coverage') async def analytics_market_coverage(days_ahead: int = 2) -> dict[str, Any]: """回傳未來賽事的實際盤口覆蓋率,避免把未接入市場包裝成正式推薦。""" safe_days_ahead = max(1, min(days_ahead, 14)) now = datetime.now(timezone.utc) window_start = now - timedelta(hours=2) window_end = now + timedelta(days=safe_days_ahead) odds_feed_markets = [ '1x2', 'asian_handicap', 'ou', 'btts', 'draw_no_bet', 'team_total', ] core_markets = {'1x2', 'asian_handicap', 'ou'} minimum_bookmakers = 2 async with SessionFactory() as session: market_rows_result = await session.execute( select( OddsHistory.market_type, func.count(OddsHistory.id), func.count(func.distinct(OddsHistory.match_id)), func.count(func.distinct(OddsHistory.bookmaker_id)), func.max(OddsHistory.recorded_at), ) .join(Match, OddsHistory.match_id == Match.id) .where( Match.match_time_utc >= window_start, Match.match_time_utc <= window_end, Match.status != MatchStatus.FINISHED, ) .group_by(OddsHistory.market_type) .order_by(OddsHistory.market_type.asc()) ) match_count_result = await session.execute( select(func.count()) .select_from(Match) .where( Match.match_time_utc >= window_start, Match.match_time_utc <= window_end, Match.status != MatchStatus.FINISHED, ) ) market_rows = market_rows_result.all() upcoming_matches = int(match_count_result.scalar_one() or 0) coverage = [] covered_market_keys = set() formal_market_keys = set() for market_type, rows, matches, bookmakers, latest_recorded_at in market_rows: market_key = str(market_type) covered_market_keys.add(market_key) usable_for_watchlist = int(matches or 0) > 0 and int(bookmakers or 0) >= 1 passes_formal_minimum = int(matches or 0) > 0 and int(bookmakers or 0) >= minimum_bookmakers if passes_formal_minimum: formal_market_keys.add(market_key) coverage.append( { 'market_type': market_key, 'label': localize_market_type(market_key), 'odds_rows': int(rows or 0), 'match_count': int(matches or 0), 'bookmaker_count': int(bookmakers or 0), 'latest_recorded_at': latest_recorded_at.isoformat() if latest_recorded_at else None, 'usable_for_watchlist': usable_for_watchlist, 'passes_formal_minimum': passes_formal_minimum, 'is_usable_for_recommendations': passes_formal_minimum, } ) missing_markets = [market for market in odds_feed_markets if market not in covered_market_keys] core_formal_ready = core_markets.issubset(formal_market_keys) source_status = 'full_market' if upcoming_matches > 0 and core_formal_ready else 'limited_market' return { 'status': source_status, 'window': { 'from': window_start.isoformat(), 'to': window_end.isoformat(), 'days_ahead': safe_days_ahead, }, 'upcoming_match_count': upcoming_matches, 'coverage': coverage, 'missing_markets': [ { 'market_type': market, 'label': localize_market_type(market), 'reason': '未來賽事在此玩法尚無可驗證賠率列', } for market in missing_markets ], 'derived_markets': [ { 'market_type': 'double_chance', 'label': '雙重機會', 'source': '由勝平負公平機率推導,不是獨立 odds feed key', } ], 'recommendation_policy': '只有 passes_formal_minimum=true 的玩法,才可升級為正式實盤推薦;只有 1 家來源時只能列入監控與賠率比對,不能包裝成正式高信心下注。', } @app.get('/analytics/recommendation-readiness') async def analytics_recommendation_readiness(days_ahead: int = 2) -> dict[str, Any]: """判斷目前是否具備發布正式實盤投注推薦的最低資料條件。""" safe_days_ahead = max(1, min(days_ahead, 14)) now = datetime.now(timezone.utc) window_start = now - timedelta(hours=2) window_end = now + timedelta(days=safe_days_ahead) core_markets = ['1x2', 'asian_handicap', 'ou'] secondary_markets = ['btts', 'draw_no_bet', 'team_total'] minimum_bookmakers = 2 minimum_core_market_matches = 1 ingestion_status: dict[str, Any] | None = None try: redis = Redis.from_url(REDIS_URL, decode_responses=True) raw_status = await redis.get('ingestion:odds:last_run') await redis.aclose() if raw_status: parsed_status = json.loads(raw_status) if isinstance(parsed_status, dict): ingestion_status = parsed_status except Exception as exc: ingestion_status = { 'status': 'unknown', 'message': f'Redis ingestion 狀態暫時無法讀取:{exc}', } async with SessionFactory() as session: upcoming_match_count_result = await session.execute( select(func.count()) .select_from(Match) .where( Match.match_time_utc >= window_start, Match.match_time_utc <= window_end, Match.status != MatchStatus.FINISHED, ) ) market_rows_result = await session.execute( select( OddsHistory.market_type, func.count(func.distinct(OddsHistory.match_id)), func.count(func.distinct(OddsHistory.bookmaker_id)), func.count(OddsHistory.id), func.max(OddsHistory.recorded_at), ) .join(Match, OddsHistory.match_id == Match.id) .where( Match.match_time_utc >= window_start, Match.match_time_utc <= window_end, Match.status != MatchStatus.FINISHED, ) .group_by(OddsHistory.market_type) ) upcoming_match_count = int(upcoming_match_count_result.scalar_one() or 0) market_status: dict[str, dict[str, Any]] = {} for market_type, match_count, bookmaker_count, odds_rows, latest_recorded_at in market_rows_result.all(): key = str(market_type) market_status[key] = { 'market_type': key, 'label': localize_market_type(key), 'match_count': int(match_count or 0), 'bookmaker_count': int(bookmaker_count or 0), 'odds_rows': int(odds_rows or 0), 'latest_recorded_at': latest_recorded_at.isoformat() if latest_recorded_at else None, 'passes_minimum': int(match_count or 0) >= minimum_core_market_matches and int(bookmaker_count or 0) >= minimum_bookmakers, } source_name = str((ingestion_status or {}).get('source') or 'unknown') worker_status = str((ingestion_status or {}).get('status') or 'unknown') blocking_reasons: list[str] = [] warnings: list[str] = [] if upcoming_match_count <= 0: blocking_reasons.append('未來視窗內沒有可分析賽事。') if source_name != 'the-odds-api': if 'taiwan-sports-lottery-reference' in source_name: blocking_reasons.append('目前包含台灣運彩參考盤,但仍不是多莊家正式 odds provider;只能作為最低賠率比對與預掛觀察。') else: blocking_reasons.append('目前賠率來源不是正式 odds provider,只能使用比分備援與預掛觀察。') if worker_status == 'error': blocking_reasons.append('賠率 worker 最近一次執行失敗。') for market in core_markets: status = market_status.get(market) if not status: blocking_reasons.append(f'{localize_market_type(market)} 尚無可驗證賠率列。') continue if int(status['bookmaker_count']) < minimum_bookmakers: blocking_reasons.append(f'{localize_market_type(market)} 莊家數不足,至少需要 {minimum_bookmakers} 家可比價來源。') if int(status['match_count']) < minimum_core_market_matches: blocking_reasons.append(f'{localize_market_type(market)} 尚未覆蓋未來賽事。') for market in secondary_markets: if market not in market_status: warnings.append(f'{localize_market_type(market)} 尚未覆蓋;可保留為預掛或模型推導,不升級為正式推薦。') formal_recommendations_allowed = len(blocking_reasons) == 0 if formal_recommendations_allowed: status = 'ready_for_market_review' mode = 'formal_market_recommendations' headline = '已具備正式實盤推薦的最低資料條件,仍需逐筆檢查 EV、去水機率與風險上限。' elif upcoming_match_count > 0: status = 'pre_market_watchlist_only' mode = 'watchlist_only' headline = '目前只能發布預掛觀察,不能標示為正式高勝率下注推薦。' else: status = 'blocked_no_upcoming_matches' mode = 'blocked' headline = '目前沒有未來賽事可供推薦。' return { 'status': status, 'mode': mode, 'headline': headline, 'formal_recommendations_allowed': formal_recommendations_allowed, 'window': { 'from': window_start.isoformat(), 'to': window_end.isoformat(), 'days_ahead': safe_days_ahead, }, 'source': { 'name': source_name, 'worker_status': worker_status, 'last_run': ingestion_status, }, 'thresholds': { 'minimum_bookmakers': minimum_bookmakers, 'minimum_core_market_matches': minimum_core_market_matches, 'core_markets': core_markets, 'secondary_markets': secondary_markets, }, 'upcoming_match_count': upcoming_match_count, 'market_status': [market_status[key] for key in sorted(market_status.keys())], 'blocking_reasons': blocking_reasons, 'warnings': warnings, 'required_actions': [ '接入正式 THE_ODDS_API_KEY 或等效合法賠率來源。', '確認未來賽事至少覆蓋勝平負、讓球盤、大小球三個核心玩法。', f'每個核心玩法至少需要 {minimum_bookmakers} 家莊家可比價,才可標記為正式實盤推薦。', '缺盤玩法只能出現在預掛觀察,不得用高勝率口吻包裝。', ], } @app.get('/analytics/news-snapshot') async def news_snapshot() -> dict[str, Any]: """回傳新聞排程 worker 的最新快照。""" try: redis = Redis.from_url(REDIS_URL, decode_responses=True) raw = await redis.get('news:worldcup:latest') await redis.aclose() except Exception as exc: raise HTTPException(status_code=503, detail=f'新聞快照暫時無法讀取:{exc}') from exc if not raw: raise HTTPException(status_code=404, detail='尚無新聞排程快照') try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise HTTPException(status_code=500, detail='新聞快照格式錯誤') from exc if not isinstance(parsed, dict): raise HTTPException(status_code=500, detail='新聞快照格式錯誤') return parsed 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, Match.home_score, Match.away_score, Match.result_synced_at, 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() deduped: dict[tuple[str, str, str], dict[str, Any]] = {} def quality_rank(payload: dict[str, Any]) -> tuple[int, int, int, int]: raw_status = str(payload.get('_raw_status') or '').upper() has_score = int(payload.get('home_score') is not None and payload.get('away_score') is not None) is_finished = int('FINISHED' in raw_status or payload.get('status') == '已完賽') has_result_sync = int(payload.get('_result_synced_at') is not None) stable_provider_id = int(len(str(payload.get('match_id') or '')) >= 6) return has_score, is_finished, has_result_sync, stable_provider_id for ( match_id, home_name, away_name, kickoff_utc, status, home_score, away_score, result_synced_at, venue_name, venue_city, venue_country, ) in rows: home_label = localize_team_name(home_name) away_label = localize_team_name(away_name) payload = { 'match_id': match_id, 'home_team': home_label, 'away_team': away_label, 'home_score': home_score, 'away_score': away_score, 'kickoff_utc': kickoff_utc, 'status': localize_status(str(status.value if hasattr(status, 'value') else status)), 'venue_name': localize_venue_name(venue_name), 'venue_city': localize_city(venue_city), 'venue_country': localize_country(venue_country), '_raw_status': str(status.value if hasattr(status, 'value') else status), '_result_synced_at': result_synced_at, } key = ( home_label.strip().lower(), away_label.strip().lower(), kickoff_utc.isoformat() if hasattr(kickoff_utc, 'isoformat') else str(kickoff_utc), ) current = deduped.get(key) if current is None or quality_rank(payload) > quality_rank(current): deduped[key] = payload return [ {key: value for key, value in payload.items() if not key.startswith('_')} for payload in deduped.values() ] 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, default=1.1) away_xg = _safe_float(match.away_xg, default=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=localize_market_type(str(market_type)), selection=localize_selection(str(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 ] home_xg, away_xg, preview_xg_quality = _estimate_daily_xg( match.home_xg, match.away_xg, home.name, away.name, home.fifa_rank, away.fifa_rank, home.current_elo_rating, away.current_elo_rating, bool(odds_points), ) 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=localize_team_name(home.name), away_team=localize_team_name(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': localize_team_name(home.name), 'away_team': localize_team_name(away.name), 'home_score': match.home_score, 'away_score': match.away_score, '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': localize_status(str(match.status.value if hasattr(match.status, 'value') else match.status)), 'venue_name': localize_venue_name(venue.name), 'venue_city': localize_city(venue.city), 'venue_country': localize_country(venue.country), 'venue_altitude_meters': venue.altitude_meters, 'odds_series': odds_points, 'odds_quality': 'live_market' if odds_points else 'missing_market', 'xg_quality': preview_xg_quality, '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]]: # 正式環境不得用樣本賽事製造投注推薦;沒有當日可用盤口時回傳空清單。 return matches_on_date def _clamp_model_value(value: float, lower: float, upper: float) -> float: return max(lower, min(upper, value)) def _team_strength_prior(team_name: Any) -> tuple[int | None, float | None]: normalized = str(team_name or '').strip().lower() normalized = normalized.replace('é', 'e').replace('ã', 'a').replace('ö', 'o') prior = TEAM_STRENGTH_PRIORS.get(normalized) if prior is None: return None, None return prior def _estimate_daily_xg( home_xg: Any, away_xg: Any, home_name: Any, away_name: Any, home_rank: Any, away_rank: Any, home_elo: Any, away_elo: Any, has_market_odds: bool, ) -> tuple[float, float, str]: """用實際 xG 優先;若只有賽程資料,改用 FIFA ranking / Elo 做賽前先驗,不再套同一組 1.0/1.0。""" parsed_home_xg = _safe_float(home_xg) parsed_away_xg = _safe_float(away_xg) seeded_xg = ( parsed_home_xg is not None and parsed_away_xg is not None and abs(parsed_home_xg - 1.0) < 0.0001 and abs(parsed_away_xg - 1.0) < 0.0001 and not has_market_odds ) if parsed_home_xg is not None and parsed_away_xg is not None and not seeded_xg: return parsed_home_xg, parsed_away_xg, 'observed' parsed_home_elo = _safe_float(home_elo) parsed_away_elo = _safe_float(away_elo) parsed_home_rank = _safe_float(home_rank) parsed_away_rank = _safe_float(away_rank) home_prior_rank, home_prior_elo = _team_strength_prior(home_name) away_prior_rank, away_prior_elo = _team_strength_prior(away_name) parsed_home_rank = parsed_home_rank if parsed_home_rank is not None else _safe_float(home_prior_rank) parsed_away_rank = parsed_away_rank if parsed_away_rank is not None else _safe_float(away_prior_rank) parsed_home_elo = parsed_home_elo if parsed_home_elo is not None else _safe_float(home_prior_elo) parsed_away_elo = parsed_away_elo if parsed_away_elo is not None else _safe_float(away_prior_elo) has_strength_prior = ( parsed_home_elo is not None or parsed_away_elo is not None or parsed_home_rank is not None or parsed_away_rank is not None ) elo_signal = 0.0 if parsed_home_elo is not None and parsed_away_elo is not None: elo_signal = (parsed_home_elo - parsed_away_elo) / 420.0 rank_signal = 0.0 if parsed_home_rank is not None and parsed_away_rank is not None: rank_signal = (parsed_away_rank - parsed_home_rank) / 120.0 strength_signal = _clamp_model_value((elo_signal * 0.34) + (rank_signal * 0.26), -0.45, 0.45) estimated_home = parsed_home_xg if parsed_home_xg is not None and not seeded_xg else 1.32 + strength_signal estimated_away = parsed_away_xg if parsed_away_xg is not None and not seeded_xg else 1.08 - (strength_signal * 0.78) quality = 'rank_elo_prior' if has_strength_prior else 'fallback_prior' return ( round(_clamp_model_value(float(estimated_home), 0.45, 2.65), 4), round(_clamp_model_value(float(estimated_away), 0.35, 2.45), 4), quality, ) async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str, Any]]: home_team = aliased(Team) away_team = aliased(Team) taipei_tz = timezone(timedelta(hours=8)) day_start_utc = datetime.combine(target_date, time.min, tzinfo=taipei_tz).astimezone(timezone.utc) day_end_utc = day_start_utc + timedelta(days=1) now_utc = datetime.now(timezone.utc) betting_cutoff_utc = now_utc - timedelta(minutes=20) async with SessionFactory() as session: stmt = ( select( Match.id, Match.home_xg, Match.away_xg, Match.match_time_utc, Match.status, home_team.name, away_team.name, home_team.fifa_rank, away_team.fifa_rank, home_team.current_elo_rating, away_team.current_elo_rating, ) .join(home_team, Match.home_team_id == home_team.id) .join(away_team, Match.away_team_id == away_team.id) .where( Match.match_time_utc >= day_start_utc, Match.match_time_utc < day_end_utc, Match.match_time_utc >= betting_cutoff_utc, Match.status != MatchStatus.FINISHED, ) .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, match_status, home_name, away_name, home_rank, away_rank, home_elo, away_elo, ) = 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') opening_draw, current_draw = await _query_opening_odds(session, match_id, '1x2', 'draw') _, current_over_15 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=1.5) _, current_under_15 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=1.5) _, current_over_25 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=2.5) _, current_under_25 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=2.5) _, current_over_35 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=3.5) _, current_under_35 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=3.5) _, current_btts_yes = await _query_opening_odds(session, match_id, 'btts', 'yes') _, current_btts_no = await _query_opening_odds(session, match_id, 'btts', 'no') odds_source_meta = await _query_match_odds_source_meta(session, match_id) 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 current_draw = current_draw if current_draw is not None else opening_draw has_market_odds = current_home is not None and current_away is not None and current_draw is not None estimated_home_xg, estimated_away_xg, xg_quality = _estimate_daily_xg( home_xg, away_xg, home_name, away_name, home_rank, away_rank, home_elo, away_elo, has_market_odds, ) matches_payload.append( { 'match_id': match_id, 'home_team': localize_team_name(home_name), 'away_team': localize_team_name(away_name), 'home_xg': estimated_home_xg, 'away_xg': estimated_away_xg, 'xg_quality': xg_quality, 'home_fifa_rank': home_rank, 'away_fifa_rank': away_rank, 'home_elo': home_elo, 'away_elo': away_elo, 'odds_home': float(current_home) if current_home is not None else 0.0, 'odds_away': float(current_away) if current_away is not None else 0.0, 'odds_draw': float(current_draw) if current_draw is not None else 0.0, 'odds_over_15': float(current_over_15) if current_over_15 is not None else 0.0, 'odds_under_15': float(current_under_15) if current_under_15 is not None else 0.0, 'odds_over_25': float(current_over_25) if current_over_25 is not None else 0.0, 'odds_under_25': float(current_under_25) if current_under_25 is not None else 0.0, 'odds_over_35': float(current_over_35) if current_over_35 is not None else 0.0, 'odds_under_35': float(current_under_35) if current_under_35 is not None else 0.0, 'odds_btts_yes': float(current_btts_yes) if current_btts_yes is not None else 0.0, 'odds_btts_no': float(current_btts_no) if current_btts_no is not None else 0.0, 'kickoff_utc': match_time_utc, 'status': match_status.value if hasattr(match_status, 'value') else str(match_status), 'has_market_odds': has_market_odds, 'odds_quality': 'live_market' if has_market_odds else 'missing_market', 'odds_source_label': odds_source_meta['label'], 'odds_source_kind': odds_source_meta['kind'], }, ) deduped: dict[tuple[str, str, str], dict[str, Any]] = {} for item in matches_payload: kickoff_key = item['kickoff_utc'].isoformat() if hasattr(item['kickoff_utc'], 'isoformat') else str(item['kickoff_utc']) key = (str(item['home_team']), str(item['away_team']), kickoff_key) existing = deduped.get(key) if existing is None or (item.get('has_market_odds') and not existing.get('has_market_odds')): deduped[key] = item return _build_daily_card_fallback(list(deduped.values())) async def _query_finished_recommendation_snapshots(days_back: int) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: home_team = aliased(Team) away_team = aliased(Team) end_utc = datetime.now(timezone.utc) start_utc = end_utc - timedelta(days=days_back) async with SessionFactory() as session: stmt = ( select( Match.id, Match.home_xg, Match.away_xg, Match.match_time_utc, Match.status, Match.home_score, Match.away_score, home_team.name, away_team.name, home_team.fifa_rank, away_team.fifa_rank, home_team.current_elo_rating, away_team.current_elo_rating, ) .join(home_team, Match.home_team_id == home_team.id) .join(away_team, Match.away_team_id == away_team.id) .where( Match.match_time_utc >= start_utc, Match.match_time_utc <= end_utc, Match.status == MatchStatus.FINISHED, Match.home_score.is_not(None), Match.away_score.is_not(None), ) .order_by(Match.match_time_utc.desc()) .limit(120) ) result = await session.execute(stmt) rows = result.all() matches_payload: list[dict[str, Any]] = [] result_lookup: dict[str, dict[str, Any]] = {} for row in rows: ( match_id, home_xg, away_xg, match_time_utc, match_status, home_score, away_score, home_name, away_name, home_rank, away_rank, home_elo, away_elo, ) = 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') opening_draw, current_draw = await _query_opening_odds(session, match_id, '1x2', 'draw') _, current_over_15 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=1.5) _, current_under_15 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=1.5) _, current_over_25 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=2.5) _, current_under_25 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=2.5) _, current_over_35 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=3.5) _, current_under_35 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=3.5) _, current_btts_yes = await _query_opening_odds(session, match_id, 'btts', 'yes') _, current_btts_no = await _query_opening_odds(session, match_id, 'btts', 'no') odds_source_meta = await _query_match_odds_source_meta(session, match_id) 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 current_draw = current_draw if current_draw is not None else opening_draw has_market_odds = current_home is not None and current_away is not None and current_draw is not None estimated_home_xg, estimated_away_xg, xg_quality = _estimate_daily_xg( home_xg, away_xg, home_name, away_name, home_rank, away_rank, home_elo, away_elo, has_market_odds, ) localized_home = localize_team_name(home_name) localized_away = localize_team_name(away_name) result_lookup[str(match_id)] = { 'match_id': str(match_id), 'home_team': localized_home, 'away_team': localized_away, 'home_score': int(home_score), 'away_score': int(away_score), } matches_payload.append( { 'match_id': match_id, 'home_team': localized_home, 'away_team': localized_away, 'home_xg': estimated_home_xg, 'away_xg': estimated_away_xg, 'xg_quality': xg_quality, 'home_fifa_rank': home_rank, 'away_fifa_rank': away_rank, 'home_elo': home_elo, 'away_elo': away_elo, 'odds_home': float(current_home) if current_home is not None else 0.0, 'odds_away': float(current_away) if current_away is not None else 0.0, 'odds_draw': float(current_draw) if current_draw is not None else 0.0, 'odds_over_15': float(current_over_15) if current_over_15 is not None else 0.0, 'odds_under_15': float(current_under_15) if current_under_15 is not None else 0.0, 'odds_over_25': float(current_over_25) if current_over_25 is not None else 0.0, 'odds_under_25': float(current_under_25) if current_under_25 is not None else 0.0, 'odds_over_35': float(current_over_35) if current_over_35 is not None else 0.0, 'odds_under_35': float(current_under_35) if current_under_35 is not None else 0.0, 'odds_btts_yes': float(current_btts_yes) if current_btts_yes is not None else 0.0, 'odds_btts_no': float(current_btts_no) if current_btts_no is not None else 0.0, 'kickoff_utc': match_time_utc, 'status': match_status.value if hasattr(match_status, 'value') else str(match_status), 'has_market_odds': has_market_odds, 'odds_quality': 'settled_market' if has_market_odds else 'settled_without_market', 'odds_source_label': odds_source_meta['label'], 'odds_source_kind': odds_source_meta['kind'], }, ) return _build_daily_card_fallback(matches_payload), result_lookup def _all_daily_card_items(card_payload: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for group_name in ('safe_singles', 'high_risk_singles', 'safe_parlays', 'sgp_lotteries'): raw_group = card_payload.get(group_name, []) if isinstance(raw_group, list): items.extend([item for item in raw_group if isinstance(item, dict)]) return items def _taipei_today_date(): return datetime.now(timezone(timedelta(hours=8))).date() async def _read_daily_recommendation_snapshot_payload(target_date: str) -> dict[str, Any] | None: snapshot_id = f'daily-card:{target_date}' async with SessionFactory() as session: snapshot = await session.get(DailyRecommendationSnapshot, snapshot_id) if snapshot is None or not snapshot.payload: return None payload = dict(snapshot.payload) payload.setdefault('snapshot_status', 'saved_snapshot') return payload def _with_missing_snapshot_notice(target_date: str, card_payload: dict[str, Any], match_count: int = 0) -> dict[str, Any]: """避免已開賽/完賽日期被空推薦誤解為資料正常。""" payload = dict(card_payload) quality_summary = dict(payload.get('data_quality_summary') or {}) quality_summary['snapshot_missing_after_kickoff'] = match_count payload['data_quality_summary'] = quality_summary payload['market_data_status'] = 'snapshot_missing_after_kickoff' payload['execution_policy'] = '此日期已開賽或完賽,但缺少可用賽前推薦快照;系統不得事後補造投注推薦,只能等待賽後校準或下一個賽事日。' payload['summary'] = ( f'{target_date} 的賽事已開賽或完賽,且目前沒有可用的賽前投注快照。' '為避免事後看答案補造高勝率推薦,此頁不產生新的下注建議;請改看賽後校準或下一個有盤口的日期。' ) return payload def _daily_snapshot_item_key(item: dict[str, Any]) -> tuple[str, str, str, str]: return ( str(item.get('match_id') or ''), str(item.get('market_type') or ''), str(item.get('selection') or ''), str(item.get('recommendation') or ''), ) def _merge_daily_recommendation_snapshot(existing_payload: dict[str, Any], next_payload: dict[str, Any]) -> dict[str, Any]: """保留同日較早快照中的候選,避免開賽 cutoff 後被較短清單覆蓋。""" merged = dict(next_payload) bucket_names = ('safe_singles', 'high_risk_singles', 'safe_parlays', 'sgp_lotteries') preserved_count = 0 for bucket_name in bucket_names: next_items = [item for item in next_payload.get(bucket_name, []) if isinstance(item, dict)] existing_items = [item for item in existing_payload.get(bucket_name, []) if isinstance(item, dict)] seen = {_daily_snapshot_item_key(item) for item in next_items} preserved_items: list[dict[str, Any]] = [] for item in existing_items: key = _daily_snapshot_item_key(item) if key in seen: continue preserved_items.append(item) seen.add(key) preserved_count += len(preserved_items) merged[bucket_name] = [*next_items, *preserved_items] merged_items = _all_daily_card_items(merged) total_units = round(sum(float(item.get('stake_units') or 0) for item in merged_items), 2) total_amount = sum( int(item.get('stake_amount_twd') or round(float(item.get('stake_units') or 0) * float(item.get('unit_size_twd') or 1000))) for item in merged_items ) live_count = sum(1 for item in merged_items if item.get('has_market_odds') is True) quality_counts: dict[str, int] = {} for item in merged_items: quality = str(item.get('data_quality') or 'unknown') quality_counts[quality] = quality_counts.get(quality, 0) + 1 merged['total_daily_unit_recommendation'] = total_units merged['total_daily_amount_twd'] = total_amount merged['data_quality_summary'] = { **quality_counts, 'live_market_count': live_count, 'pre_market_count': len(merged_items) - live_count, 'preserved_snapshot_items': preserved_count, } merged['matched_matches'] = max( int(next_payload.get('matched_matches') or 0), int(existing_payload.get('matched_matches') or 0), ) if merged_items: merged['market_data_status'] = 'live_market_available' if live_count > 0 else 'pre_market_watchlist' if preserved_count: base_summary = str(merged.get('summary') or '') preserve_note = f' 已保留較早賽前快照中的 {preserved_count} 組候選,避免開賽後被短清單覆蓋。' if preserve_note.strip() not in base_summary: merged['summary'] = f'{base_summary}{preserve_note}' return merged async def _persist_daily_recommendation_snapshot(target_date: str, card_payload: dict[str, Any]) -> None: """保存每日作戰室賽前快照,供賽後校準使用。""" items = _all_daily_card_items(card_payload) live_market_count = sum(1 for item in items if item.get('has_market_odds') is True) snapshot_id = f'daily-card:{target_date}' generated_at = datetime.now(timezone.utc) try: target_day = _to_date(target_date) except HTTPException: target_day = _taipei_today_date() if target_day < _taipei_today_date(): return if not items and target_day <= _taipei_today_date(): async with SessionFactory() as session: existing = await session.get(DailyRecommendationSnapshot, snapshot_id) if existing is not None and existing.payload and _all_daily_card_items(dict(existing.payload)): return async with SessionFactory() as session: existing = await session.get(DailyRecommendationSnapshot, snapshot_id) if existing is not None and existing.payload and target_day <= _taipei_today_date(): existing_payload = dict(existing.payload) if _all_daily_card_items(existing_payload): card_payload = _merge_daily_recommendation_snapshot(existing_payload, card_payload) items = _all_daily_card_items(card_payload) live_market_count = sum(1 for item in items if item.get('has_market_odds') is True) if existing is None: session.add( DailyRecommendationSnapshot( id=snapshot_id, target_date=target_date, generated_at=generated_at, items_count=len(items), live_market_count=live_market_count, pre_market_count=len(items) - live_market_count, payload=card_payload, ) ) else: existing.generated_at = generated_at existing.items_count = len(items) existing.live_market_count = live_market_count existing.pre_market_count = len(items) - live_market_count existing.payload = card_payload existing.updated_at = generated_at await session.commit() async def _query_daily_recommendation_snapshot_payloads(days_back: int) -> list[dict[str, Any]]: """讀取最近一段期間保存過的每日推薦快照。""" taipei_today = datetime.now(timezone(timedelta(hours=8))).date() start_date = (taipei_today - timedelta(days=days_back + 2)).isoformat() end_date = taipei_today.isoformat() async with SessionFactory() as session: result = await session.execute( select(DailyRecommendationSnapshot) .where(DailyRecommendationSnapshot.target_date >= start_date) .where(DailyRecommendationSnapshot.target_date <= end_date) .order_by(DailyRecommendationSnapshot.target_date.asc(), DailyRecommendationSnapshot.generated_at.asc()) ) return [snapshot.payload for snapshot in result.scalars().all() if snapshot.payload] def _item_references_finished_match(item: dict[str, Any], finished_match_ids: set[str]) -> bool: """確認推薦項目是否對應到已完賽場次。""" match_id = item.get('match_id') if match_id is not None and str(match_id) in finished_match_ids: return True for leg in item.get('legs') or []: if not isinstance(leg, dict): continue leg_match_id = leg.get('match_id') if leg_match_id is not None and str(leg_match_id) in finished_match_ids: return True return False def _snapshot_items_for_finished_matches( snapshots: list[dict[str, Any]], result_lookup: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: """從賽前快照取出已能結算的推薦項目,並避免重複計算。""" finished_match_ids = {str(match_id) for match_id in result_lookup.keys()} seen: set[str] = set() items: list[dict[str, Any]] = [] for snapshot in snapshots: for item in _all_daily_card_items(snapshot): if not _item_references_finished_match(item, finished_match_ids): continue dedupe_key = '|'.join( [ str(item.get('match_id') or ''), str(item.get('market_type') or item.get('portfolio_type') or ''), str(item.get('selection') or item.get('title') or ''), str(item.get('minimum_acceptable_odds') or item.get('target_odds') or ''), str(item.get('recommended_stake_units') or item.get('suggested_units') or ''), ] ) if dedupe_key in seen: continue seen.add(dedupe_key) item['snapshot_based'] = True items.append(item) return items def _parse_market_line(*texts: str, default: float | None = None) -> float | None: for text in texts: match = re.search(r'(\d+(?:\.\d+)?)', str(text or '')) if match: return float(match.group(1)) return default def _selection_side(selection: str, result: dict[str, Any]) -> str | None: text = str(selection or '') home_team = str(result.get('home_team') or '') away_team = str(result.get('away_team') or '') if '主勝' in text or '主隊' in text or '主不敗' in text: return 'home' if '客勝' in text or '客隊' in text or '客不敗' in text: return 'away' if home_team and home_team in text: return 'home' if away_team and away_team in text: return 'away' return None def _outcome_payload(outcome: str, label: str, lesson: str) -> dict[str, str]: return { 'outcome': outcome, 'outcome_label': label, 'lesson': lesson, } def _evaluate_market_selection(market_type: str, selection: str, result: dict[str, Any]) -> dict[str, str]: market = str(market_type or '') selected = str(selection or '') home_score = int(result.get('home_score', 0)) away_score = int(result.get('away_score', 0)) total_goals = home_score + away_score side = _selection_side(selected, result) if '正確比分' in market or re.search(r'\b\d+\s*[-::]\s*\d+\b', selected): score_match = re.search(r'\b(\d+)\s*[-::]\s*(\d+)\b', selected) if score_match: predicted_home = int(score_match.group(1)) predicted_away = int(score_match.group(2)) is_hit = predicted_home == home_score and predicted_away == away_score return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '正確比分屬於高波動玩法,命中可保留小注策略,未命中則不應放大注碼。', ) if '大小球' in market or market.lower() in {'ou', 'over_under'}: line = _parse_market_line(market, selected, default=2.5) wants_over = '大' in selected or 'over' in selected.lower() wants_under = '小' in selected or 'under' in selected.lower() if line is not None and (wants_over or wants_under): if abs(total_goals - line) < 0.0001: return _outcome_payload('push', '退回', '總進球剛好落在盤口,視為走盤,不納入命中率分母。') is_hit = total_goals > line if wants_over else total_goals < line return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '大小球會直接回饋進球分布模型;連續失準時要調整節奏、傷停與賽事強度權重。', ) if '雙方進球' in market or 'btts' in market.lower() or '雙方進球' in selected: wants_yes = '是' in selected or 'yes' in selected.lower() or ('否' not in selected and 'no' not in selected.lower()) both_scored = home_score > 0 and away_score > 0 is_hit = both_scored if wants_yes else not both_scored return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '雙方進球會檢查兩隊攻守平衡;若失準,後續要降低單靠總進球推導出的信心。', ) if '隊伍總進球' in market or 'team total' in market.lower(): line = _parse_market_line(market, selected, default=0.5) target_goals = home_score if side == 'home' else away_score if side == 'away' else None wants_over = '大' in selected or '超過' in selected or 'over' in selected.lower() wants_under = '小' in selected or '低於' in selected or 'under' in selected.lower() if target_goals is not None and line is not None and (wants_over or wants_under): if abs(target_goals - line) < 0.0001: return _outcome_payload('push', '退回', '隊伍進球數剛好落在盤口,視為走盤。') is_hit = target_goals > line if wants_over else target_goals < line return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '隊伍總進球會回饋單隊攻擊與對手防守估計,連續失準時要修正單隊 xG 權重。', ) if '平手退回' in market or '平手退回' in selected or 'draw no bet' in market.lower(): if home_score == away_score: return _outcome_payload('push', '退回', '平手退回遇到和局不算輸,這類結果不納入命中率分母。') if side == 'home': is_hit = home_score > away_score elif side == 'away': is_hit = away_score > home_score else: return _outcome_payload('not_evaluable', '待人工檢查', '這張平手退回缺少清楚主客隊選項,先不納入自動命中率。') return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '平手退回主要驗證勝負方向,未命中時要檢查是否高估強隊下限。', ) if '雙重機會' in market or '不敗' in selected: if side == 'home': is_hit = home_score >= away_score elif side == 'away': is_hit = away_score >= home_score else: return _outcome_payload('not_evaluable', '待人工檢查', '雙重機會選項缺少清楚主客隊方向,先不納入自動命中率。') return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '雙重機會命中率應高於單勝;若未達標,後續要下修保守玩法信心。', ) if '勝平負' in market or market.lower() in {'1x2', 'moneyline'} or '主勝' in selected or '客勝' in selected or '平局' in selected: if '平局' in selected or '和局' in selected: is_hit = home_score == away_score elif side == 'home': is_hit = home_score > away_score elif side == 'away': is_hit = away_score > home_score else: return _outcome_payload('not_evaluable', '待人工檢查', '勝平負選項缺少明確方向,先不納入自動命中率。') return _outcome_payload( 'hit' if is_hit else 'miss', '命中' if is_hit else '未命中', '勝平負會直接校準基本勝率模型;未命中會降低相似信心分數與建議注碼。', ) return _outcome_payload('not_evaluable', '待人工檢查', '此玩法目前沒有完整自動判定規則,需人工補規則後再納入命中率。') def _split_leg_market_selection(raw_market: str, raw_selection: str) -> tuple[str, str]: selected = str(raw_selection or '') market = str(raw_market or '') if '|' in selected: prefix, suffix = selected.split('|', 1) if prefix.strip(): market = prefix.strip() if suffix.strip(): selected = suffix.strip() return market, selected def _evaluate_recommendation_item(item: dict[str, Any], result_lookup: dict[str, dict[str, Any]]) -> dict[str, str]: legs = item.get('legs') if isinstance(item.get('legs'), list) else [] if legs: leg_outcomes = [] for leg in legs: if not isinstance(leg, dict): continue leg_match_id = str(leg.get('match_id') or item.get('match_id') or '') leg_result = result_lookup.get(leg_match_id) if not leg_result: leg_outcomes.append('not_evaluable') continue leg_market, leg_selection = _split_leg_market_selection( str(leg.get('market_type') or item.get('market_type') or ''), str(leg.get('selection') or ''), ) evaluated = _evaluate_market_selection(leg_market, leg_selection, leg_result) leg_outcomes.append(evaluated['outcome']) if not leg_outcomes or 'not_evaluable' in leg_outcomes: return _outcome_payload('not_evaluable', '待人工檢查', '串關腿數中有玩法無法自動判定,先不納入命中率。') if 'miss' in leg_outcomes: return _outcome_payload('miss', '未命中', '串關只要任一腿未中就失敗;後續會降低同類組合與高相關腿數的權重。') if all(outcome == 'push' for outcome in leg_outcomes): return _outcome_payload('push', '退回', '串關所有腿都走盤,視為退回,不納入命中率分母。') return _outcome_payload('hit', '命中', '串關全部可判定腿數通過,可保留但仍需限制注碼,避免高相關風險。') match_id = str(item.get('match_id') or '') result = result_lookup.get(match_id) if not result: return _outcome_payload('not_evaluable', '待人工檢查', '找不到對應賽果,先不納入命中率。') return _evaluate_market_selection(str(item.get('market_type') or ''), str(item.get('selection') or ''), result) def _result_score_label(item: dict[str, Any], result_lookup: dict[str, dict[str, Any]]) -> str: match_id = str(item.get('match_id') or '') result = result_lookup.get(match_id) if not result: return '無賽果' return f"{result['home_team']} {result['home_score']} - {result['away_score']} {result['away_team']}" def _performance_actions(hit_rate: float, buckets: list[dict[str, Any]], items: list[RecommendationPerformanceItem]) -> list[str]: actions: list[str] = [] if not items: return ['近端期間沒有可重建的推薦清單;下一步要先累積賽前推薦快照,才能做更嚴格的長期校準。'] no_market_count = sum(1 for item in items if item.has_market_odds is False) if hit_rate < 52: actions.append('整體命中率未達穩定門檻時,低資料品質與沒有實盤賠率的候選只能放入監控,不應標成正式下注推薦。') if no_market_count: actions.append(f'有 {no_market_count} 組候選缺少完整實盤賠率,後續會持續壓低信心與新台幣上限,直到盤口資料補齊。') weak_markets = [ bucket for bucket in buckets if int(bucket.get('settled_count', 0)) >= 3 and float(bucket.get('hit_rate_percent', 0.0)) < 45.0 ] for bucket in weak_markets[:3]: actions.append(f"{bucket['market_type']} 近端命中率偏低,接下來要降低同玩法權重並提高最低可接受賠率門檻。") if not actions: actions.append('近端命中率尚可,下一步重點是保存賽前推薦快照,追蹤是否真的拿到建議賠率與收盤價差。') return actions async def _build_recommendation_performance(days_back: int) -> RecommendationPerformanceResponse: match_payload, result_lookup = await _query_finished_recommendation_snapshots(days_back) generated_at = datetime.now(timezone.utc).isoformat() snapshot_payloads = await _query_daily_recommendation_snapshot_payloads(days_back) snapshot_items = _snapshot_items_for_finished_matches(snapshot_payloads, result_lookup) snapshot_mode = len(snapshot_items) > 0 if snapshot_mode: raw_items = snapshot_items else: card = generate_daily_card(datetime.now(timezone(timedelta(hours=8))).date().isoformat(), match_payload) raw_items = _all_daily_card_items(card) performance_items: list[RecommendationPerformanceItem] = [] bucket_state: dict[str, dict[str, int]] = defaultdict(lambda: { 'recommendation_count': 0, 'settled_count': 0, 'hit_count': 0, 'miss_count': 0, 'push_count': 0, }) source_bucket_state: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: { 'recommendation_count': 0, 'settled_count': 0, 'hit_count': 0, 'miss_count': 0, 'push_count': 0, }) hit_count = 0 miss_count = 0 push_count = 0 settled_count = 0 for item in raw_items: evaluated = _evaluate_recommendation_item(item, result_lookup) outcome = evaluated['outcome'] market_type = str(item.get('market_type') or '未分類玩法') source_label = str(item.get('odds_source_label') or ('實盤盤口' if item.get('has_market_odds') else '模型推算最低門檻')) source_kind = str(item.get('odds_source_kind') or ('market' if item.get('has_market_odds') else 'conditional_threshold')) source_key = (source_label, source_kind) bucket_state[market_type]['recommendation_count'] += 1 source_bucket_state[source_key]['recommendation_count'] += 1 if outcome in {'hit', 'miss', 'push'}: settled_count += 1 bucket_state[market_type]['settled_count'] += 1 source_bucket_state[source_key]['settled_count'] += 1 if outcome == 'hit': hit_count += 1 bucket_state[market_type]['hit_count'] += 1 source_bucket_state[source_key]['hit_count'] += 1 elif outcome == 'miss': miss_count += 1 bucket_state[market_type]['miss_count'] += 1 source_bucket_state[source_key]['miss_count'] += 1 elif outcome == 'push': push_count += 1 bucket_state[market_type]['push_count'] += 1 source_bucket_state[source_key]['push_count'] += 1 performance_items.append( RecommendationPerformanceItem( match_id=str(item.get('match_id') or ''), match_label=str(item.get('match_label') or ''), market_type=market_type, selection=str(item.get('selection') or ''), recommendation=str(item.get('recommendation') or '研究候選'), result_score=_result_score_label(item, result_lookup), outcome=outcome, outcome_label=evaluated['outcome_label'], target_odds=float(item.get('target_odds') or 1.01), win_prob=float(item.get('win_prob') or 0.0), ev_percent=float(item.get('ev_percent') or 0.0), stake_units=float(item.get('stake_units') or 0.0), stake_amount_twd=int(item['stake_amount_twd']) if item.get('stake_amount_twd') is not None else None, confidence_score=float(item['confidence_score']) if item.get('confidence_score') is not None else None, confidence_band=str(item.get('confidence_band')) if item.get('confidence_band') else None, has_market_odds=bool(item.get('has_market_odds')) if item.get('has_market_odds') is not None else None, odds_source_label=source_label, odds_source_kind=source_kind, lesson=evaluated['lesson'], ) ) denominator = hit_count + miss_count hit_rate = round((hit_count / denominator) * 100, 2) if denominator else 0.0 buckets: list[dict[str, Any]] = [] for market_type, state in bucket_state.items(): market_denominator = state['hit_count'] + state['miss_count'] market_hit_rate = round((state['hit_count'] / market_denominator) * 100, 2) if market_denominator else 0.0 buckets.append({ 'market_type': market_type, 'recommendation_count': state['recommendation_count'], 'settled_count': state['settled_count'], 'hit_count': state['hit_count'], 'miss_count': state['miss_count'], 'push_count': state['push_count'], 'hit_rate_percent': market_hit_rate, }) buckets.sort(key=lambda row: (int(row['settled_count']), float(row['hit_rate_percent'])), reverse=True) source_buckets: list[dict[str, Any]] = [] for (source_label, source_kind), state in source_bucket_state.items(): source_denominator = state['hit_count'] + state['miss_count'] source_hit_rate = round((state['hit_count'] / source_denominator) * 100, 2) if source_denominator else 0.0 source_buckets.append({ 'source_label': source_label, 'source_kind': source_kind, 'recommendation_count': state['recommendation_count'], 'settled_count': state['settled_count'], 'hit_count': state['hit_count'], 'miss_count': state['miss_count'], 'push_count': state['push_count'], 'hit_rate_percent': source_hit_rate, }) source_buckets.sort(key=lambda row: (int(row['settled_count']), float(row['hit_rate_percent'])), reverse=True) summary = ( f'近 {days_back} 天已完賽 {len(result_lookup)} 場,系統用目前模型重建 {len(raw_items)} 組推薦,' f'其中 {settled_count} 組可自動判定,命中 {hit_count} 組、未中 {miss_count} 組、退回 {push_count} 組,' f'命中率 {hit_rate:.2f}%。' ) return RecommendationPerformanceResponse( generated_at=generated_at, days_back=days_back, finished_match_count=len(result_lookup), rebuilt_recommendation_count=len(raw_items), settled_recommendation_count=settled_count, hit_count=hit_count, miss_count=miss_count, push_count=push_count, hit_rate_percent=hit_rate, summary=summary, methodology_note=( '本頁優先使用每日作戰室正式產生並保存的賽前推薦快照,等比賽結束後再回頭核對命中率、玩法表現與盤口來源;這比單純賽後重建更接近正式量化站的稽核方式。' if snapshot_mode else '目前尚未累積足夠的賽前推薦快照,因此暫時以目前模型對已完賽場次重建校準;新的每日推薦會開始保存快照,後續命中率會逐步改用真實賽前樣本。' ), improvement_actions=_performance_actions(hit_rate, buckets, performance_items), by_market_type=[RecommendationPerformanceBucket(**bucket) for bucket in buckets], by_odds_source=[RecommendationPerformanceSourceBucket(**bucket) for bucket in source_buckets], items=performance_items[:200], ) def _env_present(*names: str) -> list[str]: return [name for name in names if os.environ.get(name)] def _gemini_usage_month(now: datetime | None = None) -> str: source = now or datetime.now(timezone.utc) return source.strftime('%Y-%m') def _gemini_usage_prefix() -> str: return os.environ.get('GEMINI_USAGE_REDIS_PREFIX', 'usage:gemini') def _gemini_cap_usd() -> float: return max(0.01, float(os.environ.get('GEMINI_COST_CAP_USD', '5'))) def _gemini_input_price_per_1m() -> float: return max(0.0, float(os.environ.get('GEMINI_INPUT_PRICE_PER_1M_USD', '0.50'))) def _gemini_output_price_per_1m() -> float: return max(0.0, float(os.environ.get('GEMINI_OUTPUT_PRICE_PER_1M_USD', '3.00'))) def _gemini_grounding_price_per_1k() -> float: return max(0.0, float(os.environ.get('GEMINI_GROUNDING_PRICE_PER_1K_USD', '14.00'))) def _gemini_usage_cost(input_tokens: int, output_tokens: int, grounded_query_count: int = 0) -> float: token_cost = ( (max(0, input_tokens) / 1_000_000) * _gemini_input_price_per_1m() + (max(0, output_tokens) / 1_000_000) * _gemini_output_price_per_1m() ) grounding_cost = (max(0, grounded_query_count) / 1_000) * _gemini_grounding_price_per_1k() fallback_cost = float(os.environ.get('GEMINI_FALLBACK_REQUEST_COST_USD', '0.01')) estimated = token_cost + grounding_cost return round(max(estimated, fallback_cost if input_tokens <= 0 and output_tokens <= 0 else 0.0), 6) async def _read_gemini_usage() -> GeminiUsageResponse: generated_at = datetime.now(timezone.utc).isoformat() month = _gemini_usage_month() prefix = _gemini_usage_prefix() cap = _gemini_cap_usd() redis = Redis.from_url(REDIS_URL, decode_responses=True) try: raw = await redis.hgetall(f'{prefix}:{month}') paused_reason = await redis.get(f'{prefix}:paused') finally: await redis.aclose() estimated_cost = float(raw.get('estimated_cost_usd', 0.0) or 0.0) request_count = int(float(raw.get('request_count', 0) or 0)) input_tokens = int(float(raw.get('input_tokens', 0) or 0)) output_tokens = int(float(raw.get('output_tokens', 0) or 0)) grounded_query_count = int(float(raw.get('grounded_query_count', 0) or 0)) paused = bool(paused_reason) or estimated_cost >= cap remaining = max(0.0, cap - estimated_cost) if paused: status = 'paused_budget' status_label = '已達費用上限,Gemini 暫停' next_action = '請先檢查 Google 帳單與用量;若確認安全,再提高 GEMINI_COST_CAP_USD 或重置 Redis 暫停旗標。' elif estimated_cost >= cap * 0.8: status = 'near_limit' status_label = '接近費用上限' next_action = '建議暫時降低 Gemini 呼叫頻率,只保留重大新聞與傷停檢查。' else: status = 'ok' status_label = '費用在安全範圍' next_action = None return GeminiUsageResponse( generated_at=generated_at, month=month, status=status, status_label=status_label, paused=paused, cap_usd=round(cap, 4), estimated_cost_usd=round(estimated_cost, 6), remaining_usd=round(remaining, 6), request_count=request_count, input_tokens=input_tokens, output_tokens=output_tokens, grounded_query_count=grounded_query_count, pricing_note='以 Gemini API usageMetadata 估算 token 成本,並額外估算 Google Search grounding 查詢成本;實際帳單仍以 Google Cloud/AI Studio 為準。預設採 Gemini 3 Flash Preview Standard 價格,可用環境變數覆寫。', next_action=next_action, ) async def _record_gemini_usage( *, input_tokens: int, output_tokens: int, grounded_query_count: int = 0, ) -> GeminiUsageResponse: usage_before = await _read_gemini_usage() if usage_before.paused: return usage_before month = usage_before.month prefix = _gemini_usage_prefix() cost = _gemini_usage_cost(input_tokens, output_tokens, grounded_query_count) redis = Redis.from_url(REDIS_URL, decode_responses=True) try: key = f'{prefix}:{month}' pipe = redis.pipeline() pipe.hincrby(key, 'request_count', 1) pipe.hincrby(key, 'input_tokens', max(0, input_tokens)) pipe.hincrby(key, 'output_tokens', max(0, output_tokens)) pipe.hincrby(key, 'grounded_query_count', max(0, grounded_query_count)) pipe.hincrbyfloat(key, 'estimated_cost_usd', cost) pipe.expire(key, 60 * 60 * 24 * 62) await pipe.execute() finally: await redis.aclose() usage_after = await _read_gemini_usage() if usage_after.estimated_cost_usd >= usage_after.cap_usd: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: await redis.set( f'{prefix}:paused', json.dumps( { 'reason': 'monthly_cost_cap_reached', 'month': month, 'estimated_cost_usd': usage_after.estimated_cost_usd, 'cap_usd': usage_after.cap_usd, 'paused_at': datetime.now(timezone.utc).isoformat(), }, ensure_ascii=False, ), ) finally: await redis.aclose() return await _read_gemini_usage() return usage_after async def _read_ingestion_status_snapshot() -> dict[str, Any]: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: raw_values = { 'odds': await redis.get('ingestion:odds:last_run'), 'fixtures': await redis.get('ingestion:fixtures:last_run'), 'news': await redis.get('ingestion:news:last_run'), } finally: await redis.aclose() parsed: dict[str, Any] = {} for key, raw in raw_values.items(): if not raw: parsed[key] = {'status': 'missing'} continue try: parsed[key] = json.loads(raw) except json.JSONDecodeError: parsed[key] = {'status': 'unreadable', 'raw': str(raw)[:120]} return parsed async def _probe_json_endpoint( url: str, *, timeout_seconds: float = 8.0, headers: Mapping[str, str] | None = None, ) -> tuple[bool, str]: try: async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: response = await client.get(url, headers=headers) if 200 <= response.status_code < 300: return True, f'探測成功,HTTP {response.status_code}' return False, f'探測回應 HTTP {response.status_code}' except Exception as exc: return False, f'探測失敗:{type(exc).__name__}' async def _probe_ollama_model(base_url: str, model_name: str, *, timeout_seconds: float = 8.0) -> tuple[bool, str]: tags_url = f"{base_url.rstrip('/')}/api/tags" try: async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: response = await client.get(tags_url) if not 200 <= response.status_code < 300: return False, f'Ollama tags 回應 HTTP {response.status_code}' payload = response.json() except Exception as exc: return False, f'探測失敗:{type(exc).__name__}' models = payload.get('models', []) if isinstance(payload, dict) else [] installed_names = [ str(model.get('name') or model.get('model') or '') for model in models if isinstance(model, Mapping) ] normalized_target = model_name.split(':', 1)[0].lower() matched = [ name for name in installed_names if name.lower() == model_name.lower() or name.split(':', 1)[0].lower() == normalized_target ] if matched: return True, f'Ollama 可連,已安裝模型:{", ".join(matched[:3])}' if installed_names: return False, f'Ollama 可連,但找不到模型 {model_name};目前模型:{", ".join(installed_names[:5])}' return False, f'Ollama 可連,但尚未安裝任何模型;需要先 pull {model_name}' async def _build_agent_verification() -> AgentVerificationResponse: checked_at = datetime.now(timezone.utc).isoformat() checks: list[AgentVerificationCheck] = [] try: ingestion_status = await _read_ingestion_status_snapshot() except Exception as exc: ingestion_status = {'error': str(exc)} codex_evidence = [ '正式後端 API 已能回應此驗證端點', f"賠率 worker 狀態:{ingestion_status.get('odds', {}).get('status', 'unknown')}", f"賽程 worker 狀態:{ingestion_status.get('fixtures', {}).get('status', 'unknown')}", f"新聞 worker 狀態:{ingestion_status.get('news', {}).get('status', 'unknown')}", ] checks.append( AgentVerificationCheck( agent='Codex 工程主控', role='負責資料流、API、前端、部署與正式環境驗證', status='active', status_label='已啟用', evidence=codex_evidence, next_action='持續把 Gemini/NemoTron 的輸出接進正式推薦閘門,不讓 AI 文案直接繞過量化規則。', last_checked_at=checked_at, ) ) gemini_keys = _env_present('GEMINI_API_KEY', 'GOOGLE_API_KEY') gemini_usage = await _read_gemini_usage() if gemini_keys: gemini_model = os.environ.get('GEMINI_MODEL', 'gemini-3-flash-preview') if gemini_usage.paused: ok = False message = f'本月估算費用 ${gemini_usage.estimated_cost_usd:.4f} 已達或接近上限 ${gemini_usage.cap_usd:.2f},暫停 Gemini。' gemini_status = 'paused_budget' gemini_status_label = '因費用上限暫停' else: gemini_probe_url = os.environ.get('GEMINI_HEALTHCHECK_URL') gemini_probe_headers: dict[str, str] | None = None if not gemini_probe_url: gemini_probe_url = 'https://generativelanguage.googleapis.com/v1beta/models' gemini_probe_headers = {'x-goog-api-key': os.environ.get(gemini_keys[0], '')} ok, message = await _probe_json_endpoint(gemini_probe_url, headers=gemini_probe_headers) gemini_status = 'active' if ok else 'degraded' gemini_status_label = '已設定,可探測' if ok else '已設定,但探測異常' checks.append( AgentVerificationCheck( agent='付費 Gemini 即時情報', role='負責 Google Search grounding、URL 情報、傷停新聞與外部事件交叉查證', status=gemini_status, status_label=gemini_status_label, evidence=[ f'偵測到金鑰環境變數:{", ".join(gemini_keys)}', f'目標模型:{gemini_model}', f'本月 Gemini 估算費用:${gemini_usage.estimated_cost_usd:.4f} / ${gemini_usage.cap_usd:.2f}', message, ], next_action=None if ok else (gemini_usage.next_action or '檢查 Gemini API 金鑰權限、付費專案、網路出口與 healthcheck URL。'), last_checked_at=checked_at, ) ) else: checks.append( AgentVerificationCheck( agent='付費 Gemini 即時情報', role='負責 Google Search grounding、URL 情報、傷停新聞與外部事件交叉查證', status='pending_config', status_label='待設定', evidence=[ '正式後端未偵測到 GEMINI_API_KEY 或 GOOGLE_API_KEY。', f'費用上限已設定為 ${gemini_usage.cap_usd:.2f},目前估算 ${gemini_usage.estimated_cost_usd:.4f}。', ], next_action='在正式環境加入 GEMINI_API_KEY 與 GEMINI_MODEL,然後讓新聞/傷停摘要進入推薦前置驗證。', last_checked_at=checked_at, ) ) nemotron_base = ( os.environ.get('NEMOTRON_API_BASE') or os.environ.get('NEMOTRON_BASE_URL') or os.environ.get('OLLAMA_BASE_URL') ) nemotron_model = os.environ.get('NEMOTRON_MODEL') or os.environ.get('OLLAMA_NEMOTRON_MODEL') or 'nvidia/nemotron' if nemotron_base: is_ollama_endpoint = bool(os.environ.get('OLLAMA_BASE_URL')) or 'ollama' in nemotron_base.lower() or '1143' in nemotron_base ok, message = ( await _probe_ollama_model(nemotron_base, nemotron_model) if is_ollama_endpoint else await _probe_json_endpoint(f"{nemotron_base.rstrip('/')}/v1/models") ) checks.append( AgentVerificationCheck( agent='NemoTron 本地交叉驗證', role='負責大量候選玩法批次復核、風險分層、反方稽核與低成本長任務', status='active' if ok else 'degraded', status_label='已設定,可探測' if ok else '已設定,但探測異常', evidence=[ f'Endpoint:{nemotron_base}', f'模型:{nemotron_model}', message, ], next_action=None if ok else '確認 NemoTron/Ollama 服務是否啟動、容器網路是否可連、模型是否已 pull。', last_checked_at=checked_at, ) ) else: checks.append( AgentVerificationCheck( agent='NemoTron 本地交叉驗證', role='負責大量候選玩法批次復核、風險分層、反方稽核與低成本長任務', status='pending_config', status_label='待設定', evidence=['正式後端未偵測到 NEMOTRON_API_BASE、NEMOTRON_BASE_URL 或 OLLAMA_BASE_URL。'], next_action='在正式環境加入 NemoTron/Ollama endpoint 與模型名稱,讓候選推薦進入第二模型復核。', last_checked_at=checked_at, ) ) calibration_summary: dict[str, Any] = {} try: performance = await _build_recommendation_performance(7) calibration_summary = { 'finished_match_count': performance.finished_match_count, 'rebuilt_recommendation_count': performance.rebuilt_recommendation_count, 'settled_recommendation_count': performance.settled_recommendation_count, 'hit_rate_percent': performance.hit_rate_percent, } quant_status = 'active' if performance.settled_recommendation_count > 0 else 'degraded' checks.append( AgentVerificationCheck( agent='量化校準引擎', role='負責勝率、期望值、注碼上限、賽後命中率與玩法權重修正', status=quant_status, status_label='已啟用' if quant_status == 'active' else '已啟用,樣本不足', evidence=[ f"近 7 天已完賽:{performance.finished_match_count} 場", f"重建推薦:{performance.rebuilt_recommendation_count} 組", f"可判定推薦:{performance.settled_recommendation_count} 組", f"目前命中率:{performance.hit_rate_percent:.2f}%", ], next_action='下一階段保存每一次賽前推薦快照,讓校準從重建分析升級為完整歷史稽核。', last_checked_at=checked_at, ) ) except Exception as exc: checks.append( AgentVerificationCheck( agent='量化校準引擎', role='負責勝率、期望值、注碼上限、賽後命中率與玩法權重修正', status='blocked', status_label='異常', evidence=[f'賽後校準分析失敗:{type(exc).__name__}'], next_action='檢查 matches、odds_history 與推薦產生器資料結構。', last_checked_at=checked_at, ) ) try: readiness = await analytics_recommendation_readiness(days_ahead=2) formal_allowed = bool(readiness.get('formal_recommendations_allowed')) checks.append( AgentVerificationCheck( agent='正式推薦資料閘門', role='確認盤口覆蓋、資料來源與正式下注推薦門檻是否足夠', status='active' if formal_allowed else 'blocked', status_label='正式推薦可用' if formal_allowed else '僅能監控,暫停正式推薦', evidence=[ f"閘門狀態:{readiness.get('status_label') or readiness.get('status')}", f"正式推薦允許:{'是' if formal_allowed else '否'}", ], next_action=None if formal_allowed else '等待多來源盤口、核心玩法覆蓋與資料新鮮度通過後,才可升級為正式下注推薦。', last_checked_at=checked_at, ) ) except Exception as exc: checks.append( AgentVerificationCheck( agent='正式推薦資料閘門', role='確認盤口覆蓋、資料來源與正式下注推薦門檻是否足夠', status='blocked', status_label='推薦閘門讀取失敗', evidence=[f'推薦閘門檢查失敗:{type(exc).__name__}'], next_action='先修復 recommendation-readiness API,再允許 AI 驗證室顯示 production ready。', last_checked_at=checked_at, ) ) status_map = {check.agent: check.status for check in checks} production_ready = all( status_map.get(agent_name) == 'active' for agent_name in ('Codex 工程主控', '付費 Gemini 即時情報', 'NemoTron 本地交叉驗證', '量化校準引擎', '正式推薦資料閘門') ) if production_ready: overall_status = 'active' overall_label = '四層驗證已全部啟用' elif status_map.get('正式推薦資料閘門') == 'blocked': overall_status = 'blocked' overall_label = 'AI 可用但推薦閘門未通過,暫停正式下注推薦' elif status_map.get('量化校準引擎') == 'blocked': overall_status = 'blocked' overall_label = '量化校準異常,暫停正式推薦' else: overall_status = 'partial' overall_label = '核心量化已啟用,外部 AI 驗證待補齊' return AgentVerificationResponse( generated_at=checked_at, overall_status=overall_status, overall_label=overall_label, production_ready=production_ready, decision_policy='AI Agent 只做情報蒐集、交叉驗證與反方稽核;正式投注推薦必須通過量化勝率、期望值、賠率門檻、資料新鮮度與賽後校準,不允許單一 LLM 直接發單。', calibration_summary=calibration_summary, checks=checks, ) def _agent_recommendation_label(code: Any) -> str: labels = { 'SAFE_SINGLE': '單關保守候選', 'HIGH_RISK_SINGLE': '高波動單關候選', 'CONDITIONAL_ENTRY': '預掛條件單', 'SAFE_PARLAY': '跨場串關候選', 'SGP_LOTTERY': '同場串關小注候選', } return labels.get(str(code or ''), str(code or '未標示策略')) def _compact_agent_review_items(card_payload: dict[str, Any], limit: int = 10) -> list[dict[str, Any]]: items = _all_daily_card_items(card_payload) items.sort( key=lambda item: ( float(item.get('confidence_score') or 0.0), float(item.get('ev_percent') or 0.0), -float(item.get('stake_units') or 0.0), ), reverse=True, ) compact: list[dict[str, Any]] = [] for item in items[:limit]: compact.append( { 'match': item.get('match_label'), 'market': item.get('market_type'), 'selection': item.get('selection'), 'recommendation': _agent_recommendation_label(item.get('recommendation')), 'win_prob': item.get('win_prob'), 'ev_percent': item.get('ev_percent'), 'confidence_score': item.get('confidence_score'), 'confidence_band': item.get('confidence_band'), 'stake_amount_twd': item.get('stake_amount_twd'), 'has_market_odds': item.get('has_market_odds'), 'data_quality': item.get('data_quality'), 'risk_level': item.get('risk_level'), } ) return compact def _agent_review_timeout_seconds() -> float: raw_value = os.environ.get('NEMOTRON_REVIEW_TIMEOUT_SECONDS', '6') try: return max(4.0, min(float(raw_value), 60.0)) except ValueError: return 6.0 def _agent_review_num_predict() -> int: raw_value = os.environ.get('NEMOTRON_REVIEW_NUM_PREDICT', '120') try: return max(48, min(int(raw_value), 300)) except ValueError: return 120 def _agent_daily_review_cache_key(target_date: datetime.date) -> str: return f'agent:daily_review:{target_date.isoformat()}' def _model_payload(model: BaseModel) -> dict[str, Any]: if hasattr(model, 'model_dump'): return model.model_dump() return model.dict() async def _read_cached_agent_daily_review(target_date: datetime.date) -> AgentDailyReviewResponse | None: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: raw_value = await redis.get(_agent_daily_review_cache_key(target_date)) if not raw_value: return None payload = json.loads(raw_value) return AgentDailyReviewResponse(**payload) except Exception: logger.exception('NemoTron 每日稽核快取讀取失敗:%s', target_date.isoformat()) return None finally: await redis.aclose() async def _write_cached_agent_daily_review(review: AgentDailyReviewResponse) -> None: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: await redis.set( _agent_daily_review_cache_key(_to_date(review.date)), json.dumps(_model_payload(review), ensure_ascii=False), ex=AGENT_DAILY_REVIEW_CACHE_TTL_SECONDS, ) finally: await redis.aclose() def _build_agent_review_fallback(compact_items: list[dict[str, Any]], reason: str) -> str: ranked_items = sorted( compact_items, key=lambda item: ( item.get('has_market_odds') is True, float(item.get('confidence_score') or 0), float(item.get('ev_percent') or 0), ), ) risk_lines: list[str] = [] keep_lines: list[str] = [] for item in ranked_items[:3]: match = item.get('match') or '未標示賽事' market = item.get('market') or '未標示玩法' recommendation = item.get('selection') or item.get('recommendation') or '未標示選項' confidence = float(item.get('confidence_score') or 0) ev_percent = float(item.get('ev_percent') or 0) data_quality = item.get('data_quality') or 'unknown' has_odds = item.get('has_market_odds') is True risk_note = '缺少即時盤口' if not has_odds else f'資料品質 {data_quality}' risk_lines.append( f'{match} 的 {market}「{recommendation}」信心 {confidence:.1f}、EV {ev_percent:.2f}%,{risk_note},需等盤口更新再放大倉位。' ) for item in sorted(compact_items, key=lambda entry: float(entry.get('confidence_score') or 0), reverse=True)[:2]: match = item.get('match') or '未標示賽事' market = item.get('market') or '未標示玩法' recommendation = item.get('selection') or item.get('recommendation') or '未標示選項' keep_lines.append(f'{match} 的 {market}「{recommendation}」可保留在觀察清單,但仍要套用賠率門檻與新台幣下注上限。') return ( f'總結:{reason},系統已改用量化降級稽核,避免頁面逾時或空白。' f'\n最高風險:{";".join(risk_lines) if risk_lines else "目前沒有足夠候選可判讀風險。"}' f'\n可保留:{";".join(keep_lines) if keep_lines else "暫不放大任何推薦。"}' '\n需要等待的資料:最新盤口、實際先發、傷停、臨場水位變化與賽果回填。' ) async def _run_ollama_review(prompt: str, model: str, base_url: str, timeout_seconds: float) -> str: timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds), read=timeout_seconds, write=5.0, pool=5.0) async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( f"{base_url.rstrip('/')}/api/generate", json={ 'model': model, 'prompt': prompt, 'stream': False, 'options': { 'temperature': 0.2, 'num_predict': _agent_review_num_predict(), }, }, ) response.raise_for_status() payload = response.json() text = payload.get('response') return text.strip() if isinstance(text, str) else '' def _extract_gemini_text(payload: Mapping[str, Any]) -> str: parts: list[str] = [] for candidate in payload.get('candidates') or []: content = candidate.get('content') if isinstance(candidate, Mapping) else None if not isinstance(content, Mapping): continue for part in content.get('parts') or []: if isinstance(part, Mapping) and isinstance(part.get('text'), str): parts.append(part['text']) return '\n'.join(part.strip() for part in parts if part.strip()).strip() def _extract_gemini_token_usage(payload: Mapping[str, Any], prompt: str, text: str) -> tuple[int, int]: usage = payload.get('usageMetadata') if isinstance(usage, Mapping): input_tokens = int(float(usage.get('promptTokenCount') or usage.get('inputTokenCount') or 0)) output_tokens = int(float(usage.get('candidatesTokenCount') or usage.get('outputTokenCount') or 0)) if input_tokens or output_tokens: return max(0, input_tokens), max(0, output_tokens) return max(1, len(prompt) // 4), max(1, len(text) // 4) async def _run_gemini_agent_review( *, target_date: datetime.date, prompt: str, compact_items: list[dict[str, Any]], guardrails: list[str], nemotron_model: str, failure_reason: str, ) -> AgentDailyReviewResponse | None: api_key = os.environ.get('GEMINI_API_KEY', '').strip() if not api_key: return None usage_before = await _read_gemini_usage() if usage_before.paused or usage_before.remaining_usd <= 0.02: return None model = os.environ.get('GEMINI_REVIEW_MODEL') or os.environ.get('GEMINI_MODEL', 'gemini-3-flash-preview') gemini_items = [ { '賽事': item.get('match'), '玩法': item.get('market'), '選項': item.get('selection'), '信心分數': item.get('confidence_score'), 'EV百分比': item.get('ev_percent'), '盤口狀態': '已有盤口' if item.get('has_market_odds') else '等待盤口', '資料品質': item.get('data_quality'), '風險': item.get('risk_level'), } for item in compact_items ] gemini_prompt = ( '你是世界盃投注量化系統的付費 AI 備援稽核員。' '請根據候選清單做「反方稽核」,目標是指出哪些投注要降權、等待或小注。' '只使用候選清單裡的資訊,不得新增不存在的賽事、賠率、傷停或新聞。' '不得承諾獲利,不得鼓吹重倉。' '請用繁體中文,完全照以下四行格式輸出,每行 30 到 70 字,總字數至少 120 字:' '\n總結:' '\n最高風險:' '\n可保留:' '\n等待資料:' f'\nNemoTron 狀態:{failure_reason}' f'\n日期:{target_date.isoformat()}' f'\n候選清單:{json.dumps(gemini_items, ensure_ascii=False)}' ) try: timeout_seconds = max(8.0, min(float(os.environ.get('GEMINI_REVIEW_TIMEOUT_SECONDS', '18')), 30.0)) except ValueError: timeout_seconds = 18.0 try: async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds, connect=5.0)) as client: response = await client.post( f'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent', headers={'x-goog-api-key': api_key}, json={ 'contents': [ { 'role': 'user', 'parts': [{'text': gemini_prompt}], } ], 'generationConfig': { 'temperature': 0.15, 'maxOutputTokens': 260, }, }, ) response.raise_for_status() payload = response.json() except Exception: logger.exception('Gemini 備援稽核呼叫失敗') return None text = _extract_gemini_text(payload) input_tokens, output_tokens = _extract_gemini_token_usage(payload, gemini_prompt, text) usage_after = await _record_gemini_usage(input_tokens=input_tokens, output_tokens=output_tokens, grounded_query_count=0) if usage_after.paused and not text: return None required_labels = ('總結', '最高風險', '可保留', '等待資料') if len(text) < 80 or not all(label in text for label in required_labels): summary = _build_agent_review_fallback(compact_items, 'Gemini 備援回覆過短,改用量化降級稽核') status = 'degraded' status_label = 'Gemini 備援回覆過短,已使用量化降級稽核' raw_response = text or None else: summary = text status = 'gemini_fallback' status_label = 'Gemini 備援稽核已完成' raw_response = text return AgentDailyReviewResponse( generated_at=datetime.now(timezone.utc).isoformat(), date=target_date.isoformat(), status=status, status_label=status_label, model=f'Gemini:{model};NemoTron:{nemotron_model}', reviewed_count=len(compact_items), summary=summary, raw_response=raw_response, guardrails=guardrails + [ f'Gemini 備援已納入費用監控:本月估算 ${usage_after.estimated_cost_usd:.6f} / ${usage_after.cap_usd:.2f}。', 'Gemini 只產生反方稽核文字,不直接改寫正式下注推薦。', ], ) async def _build_agent_daily_review(target_date: datetime.date, allow_live_model: bool = True) -> AgentDailyReviewResponse: generated_at = datetime.now(timezone.utc).isoformat() base_url = os.environ.get('OLLAMA_BASE_URL') or os.environ.get('NEMOTRON_API_BASE') or os.environ.get('NEMOTRON_BASE_URL') or '' model = os.environ.get('OLLAMA_NEMOTRON_MODEL') or os.environ.get('NEMOTRON_MODEL') or 'nemotron-mini' guardrails = [ 'NemoTron 只做反方稽核與風險提示,不直接產生正式下注推薦。', '若量化勝率、期望值、賠率門檻或資料新鮮度不通過,AI 意見不能覆蓋風控。', '此流程不呼叫付費 Gemini,因此不會增加 Gemini 費用。', ] if allow_live_model: if not base_url: return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='pending_config', status_label='NemoTron endpoint 尚未設定', model=model, reviewed_count=0, summary='尚未設定 NemoTron/Ollama endpoint,無法執行反方稽核。', raw_response=None, guardrails=guardrails, ) ok, probe_message = await _probe_ollama_model(base_url, model) if not ok: return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='degraded', status_label='NemoTron 尚未可用', model=model, reviewed_count=0, summary=probe_message, raw_response=None, guardrails=guardrails, ) match_payload = await _query_match_day_snapshot(target_date) card = generate_daily_card(target_date.isoformat(), match_payload) compact_items = _compact_agent_review_items(card, limit=5 if allow_live_model else 10) if not compact_items: return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='no_candidates', status_label='沒有可稽核候選', model=model, reviewed_count=0, summary='當日沒有可稽核的推薦候選;可能是尚未開盤、資料不足,或推薦閘門全部擋下。', raw_response=None, guardrails=guardrails, ) prompt_items = [ { '玩法': item.get('market'), '選項': item.get('selection'), '信心': item.get('confidence_score'), 'EV': item.get('ev_percent'), '盤口': '有' if item.get('has_market_odds') else '缺', '品質': item.get('data_quality'), } for item in compact_items ] prompt = ( '你是世界盃投注量化系統的反方稽核員。' '請只用繁體中文輸出 4 行:總結、最高風險、可保留、等待資料。' '每行 45 字內,不新增賽事或賠率,不承諾獲利。' f'\n日期:{target_date.isoformat()}' f'\n候選:{json.dumps(prompt_items, ensure_ascii=False)}' ) if not allow_live_model: return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='pending_cache', status_label='等待背景稽核快取', model=model, reviewed_count=len(compact_items), summary=_build_agent_review_fallback(compact_items, '背景 NemoTron 稽核尚未產生快取'), raw_response=None, guardrails=guardrails, ) timeout_seconds = _agent_review_timeout_seconds() try: review_text = await asyncio.wait_for( _run_ollama_review(prompt, model, base_url, timeout_seconds), timeout=timeout_seconds + 2.0, ) except (asyncio.TimeoutError, TimeoutError, httpx.TimeoutException): reason = f'NemoTron 超過 {timeout_seconds:.0f} 秒未回應' gemini_review = await _run_gemini_agent_review( target_date=target_date, prompt=prompt, compact_items=compact_items, guardrails=guardrails, nemotron_model=model, failure_reason=reason, ) if gemini_review is not None: return gemini_review return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='degraded', status_label='NemoTron 回應逾時,已使用量化降級稽核', model=model, reviewed_count=len(compact_items), summary=_build_agent_review_fallback(compact_items, reason), raw_response=None, guardrails=guardrails, ) except Exception as exc: reason = f'NemoTron 呼叫失敗:{type(exc).__name__}' gemini_review = await _run_gemini_agent_review( target_date=target_date, prompt=prompt, compact_items=compact_items, guardrails=guardrails, nemotron_model=model, failure_reason=reason, ) if gemini_review is not None: return gemini_review return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='degraded', status_label='NemoTron 稽核失敗', model=model, reviewed_count=len(compact_items), summary=_build_agent_review_fallback(compact_items, reason), raw_response=None, guardrails=guardrails, ) return AgentDailyReviewResponse( generated_at=generated_at, date=target_date.isoformat(), status='active', status_label='NemoTron 已完成反方稽核', model=model, reviewed_count=len(compact_items), summary=review_text or 'NemoTron 已回應,但內容為空;建議保留量化閘門判斷。', raw_response=review_text, guardrails=guardrails, ) 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, market_line: float | None = None, handicap: float | None = None, ) -> tuple[float | None, float | None]: filters = [ OddsHistory.match_id == match_id, OddsHistory.market_type == market_type, OddsHistory.selection == selection, ] if market_line is not None: filters.append(OddsHistory.market_line == market_line) if handicap is not None: filters.append(OddsHistory.handicap == handicap) opening_stmt = ( select(OddsHistory.decimal_odds) .join(Match, Match.id == OddsHistory.match_id) .where(*filters) .order_by(asc(OddsHistory.recorded_at)) .limit(1) ) current_stmt = ( select(OddsHistory.decimal_odds) .where(*filters) .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, ) async def _query_match_odds_source_meta(session: Any, match_id: str) -> dict[str, str]: stmt = ( select( Bookmaker.id, Bookmaker.name, func.count(OddsHistory.id), func.max(OddsHistory.recorded_at), ) .join(OddsHistory, OddsHistory.bookmaker_id == Bookmaker.id) .where(OddsHistory.match_id == match_id) .group_by(Bookmaker.id, Bookmaker.name) ) result = await session.execute(stmt) rows = result.all() if not rows: return {'label': '模型推算最低門檻', 'kind': 'conditional_threshold'} bookmaker_ids = {str(row[0]) for row in rows} def source_timestamp(row: Any) -> float: value = row[3] if hasattr(value, 'timestamp'): try: return float(value.timestamp()) except (TypeError, ValueError, OSError): return 0.0 return 0.0 latest_row = sorted(rows, key=source_timestamp, reverse=True)[0] latest_name = str(latest_row[1] or latest_row[0]) if 'taiwan-sports-lottery-reference' in bookmaker_ids: if len(bookmaker_ids) > 1: return {'label': f'台灣運彩參考盤 + {len(bookmaker_ids) - 1} 個來源', 'kind': 'reference_market'} return {'label': '台灣運彩參考盤', 'kind': 'reference_market'} if len(bookmaker_ids) >= 2: return {'label': f'多來源盤口({len(bookmaker_ids)} 個來源)', 'kind': 'multi_provider_market'} if 'espn-odds' in bookmaker_ids or 'espn' in latest_name.lower(): return {'label': 'ESPN 比分備援盤', 'kind': 'scoreboard_fallback'} return {'label': latest_name, 'kind': 'single_provider_market'} 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'], home_score=match_payload.get('home_score'), away_score=match_payload.get('away_score'), 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_score=payload.get('home_score'), away_score=payload.get('away_score'), 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) def _daily_card_calendar_cache_key(start_date: str, end_date: str | None = None) -> str: end_part = end_date or 'auto' return f'daily-card-calendar:v2:{start_date}:{end_part}' async def _read_cached_daily_card_calendar(cache_key: str) -> dict[str, Any] | None: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: raw = await redis.get(cache_key) if not raw: return None ttl = await redis.ttl(cache_key) payload = json.loads(raw) if not isinstance(payload, dict): return None payload = dict(payload) payload['cache_status'] = 'hit' payload['cache_remaining_seconds'] = max(0, int(ttl or 0)) payload['served_at'] = datetime.now(timezone.utc).isoformat() return payload except Exception as exc: logger.warning('日期推薦摘要快取讀取失敗:%s', exc) return None finally: await redis.aclose() async def _write_cached_daily_card_calendar(cache_key: str, payload: dict[str, Any]) -> None: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: await redis.setex( cache_key, DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, json.dumps(payload, ensure_ascii=False, default=str), ) except Exception as exc: logger.warning('日期推薦摘要快取寫入失敗:%s', exc) finally: await redis.aclose() async def _build_daily_card_calendar_payload(start_date: str = '2026-06-11', end_date: str | None = None) -> dict[str, Any]: start = _to_date(start_date).isoformat() end = _to_date(end_date).isoformat() if end_date else None match_rows = await _query_match_list(limit=1000) match_counts: dict[str, int] = defaultdict(int) for match_payload in match_rows: kickoff = match_payload.get('kickoff_utc') try: if isinstance(kickoff, datetime): kickoff_dt = kickoff else: kickoff_dt = datetime.fromisoformat(str(kickoff).replace('Z', '+00:00')) if kickoff_dt.tzinfo is None: kickoff_dt = kickoff_dt.replace(tzinfo=timezone.utc) taipei_date = kickoff_dt.astimezone(timezone(timedelta(hours=8))).date().isoformat() except (TypeError, ValueError): continue if taipei_date < start: continue if end is not None and taipei_date > end: continue match_counts[taipei_date] += 1 dates: list[dict[str, Any]] = [] for target_date in sorted(match_counts.keys()): target_day = _to_date(target_date) snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) if target_day <= _taipei_today_date() and snapshot_payload and _all_daily_card_items(snapshot_payload): card = snapshot_payload else: match_payload = await _query_match_day_snapshot(target_day) card = generate_daily_card(target_date, match_payload) if target_day <= _taipei_today_date() and not _all_daily_card_items(card) and match_counts[target_date] > 0: card = _with_missing_snapshot_notice(target_date, card, match_counts[target_date]) if target_day >= _taipei_today_date() and _all_daily_card_items(card): await _persist_daily_recommendation_snapshot(target_date, card) snapshot_payload = card items = _all_daily_card_items(card) snapshot_item_count = len(_all_daily_card_items(snapshot_payload)) if snapshot_payload else 0 snapshot_status = 'saved' if snapshot_item_count <= 0: snapshot_status = 'missing_after_kickoff' if card.get('market_data_status') == 'snapshot_missing_after_kickoff' else 'not_saved_yet' live_count = sum(1 for item in items if item.get('has_market_odds') is True) total_amount_twd = card.get('total_daily_amount_twd') if total_amount_twd is None: total_amount_twd = round( float(card.get('total_daily_unit_recommendation') or 0) * float(card.get('unit_size_twd') or 1000) ) dates.append( { 'date': target_date, 'match_count': match_counts[target_date], 'matched_matches': card.get('matched_matches', 0), 'recommendation_count': len(items), 'live_count': live_count, 'watch_count': len(items) - live_count, 'safe_single_count': len(card.get('safe_singles') or []), 'high_risk_single_count': len(card.get('high_risk_singles') or []), 'safe_parlay_count': len(card.get('safe_parlays') or []), 'sgp_lottery_count': len(card.get('sgp_lotteries') or []), 'total_amount_twd': total_amount_twd, 'market_data_status': card.get('market_data_status'), 'snapshot_status': snapshot_status, 'snapshot_item_count': snapshot_item_count, 'snapshot_preserved_count': int((card.get('data_quality_summary') or {}).get('preserved_snapshot_items') or 0), 'summary': card.get('summary'), } ) return { 'generated_at': datetime.now(timezone.utc).isoformat(), 'start_date': start, 'end_date': end or (dates[-1]['date'] if dates else start), 'dates': dates, 'cache_status': 'generated', 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, } @app.get('/analytics/daily-card-calendar') async def daily_card_calendar_route(start_date: str = '2026-06-11', end_date: str | None = None) -> dict[str, Any]: """依世界盃日期產生賽事與推薦摘要,供首頁與作戰室日期盤使用。""" start = _to_date(start_date).isoformat() end = _to_date(end_date).isoformat() if end_date else None cache_key = _daily_card_calendar_cache_key(start, end) cached = await _read_cached_daily_card_calendar(cache_key) if cached: return cached payload = await _build_daily_card_calendar_payload(start, end) await _write_cached_daily_card_calendar(cache_key, payload) return payload @app.get('/analytics/daily-card-calendar/status') async def daily_card_calendar_status_route() -> dict[str, Any]: redis = Redis.from_url(REDIS_URL, decode_responses=True) try: raw = await redis.get(DAILY_CARD_CALENDAR_STATUS_KEY) if not raw: return { 'status': 'unknown', 'status_label': '日期摘要快取 worker 尚未回報', 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, } payload = json.loads(raw) if isinstance(payload, dict): return payload finally: await redis.aclose() return { 'status': 'error', 'status_label': '日期摘要快取 worker 狀態格式異常', 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, } @app.get('/analytics/daily-card/{target_date}', response_model=DailyCardResponse) async def generate_daily_card_route(target_date: str) -> DailyCardResponse: target_day = _to_date(target_date) snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) if target_day <= _taipei_today_date() else None if target_day < _taipei_today_date() and snapshot_payload: return DailyCardResponse(**snapshot_payload) match_payload = await _query_match_day_snapshot(target_day) result = generate_daily_card(target_date, match_payload) if target_day <= _taipei_today_date() and not _all_daily_card_items(result): if snapshot_payload and _all_daily_card_items(snapshot_payload): return DailyCardResponse(**snapshot_payload) return DailyCardResponse(**_with_missing_snapshot_notice(target_date, result)) if target_day >= _taipei_today_date(): await _persist_daily_recommendation_snapshot(target_date, result) return DailyCardResponse(**result) @app.get('/analytics/recommendation-performance', response_model=RecommendationPerformanceResponse) async def recommendation_performance_route(days_back: int = 7) -> RecommendationPerformanceResponse: normalized_days_back = max(1, min(days_back, 30)) return await _build_recommendation_performance(normalized_days_back) @app.get('/analytics/agent-verification', response_model=AgentVerificationResponse) async def agent_verification_route() -> AgentVerificationResponse: return await _build_agent_verification() @app.get('/analytics/gemini-usage', response_model=GeminiUsageResponse) async def gemini_usage_route() -> GeminiUsageResponse: return await _read_gemini_usage() @app.get('/analytics/agent-daily-review/{target_date}', response_model=AgentDailyReviewResponse) async def agent_daily_review_route(target_date: str) -> AgentDailyReviewResponse: review_date = _to_date(target_date) cached_review = await _read_cached_agent_daily_review(review_date) if cached_review is not None: return cached_review return await _build_agent_daily_review(review_date, allow_live_model=False) if __name__ == '__main__': import uvicorn uvicorn.run('main:app', host='0.0.0.0', port=int(os.environ.get('PORT', '8000')))