Files
2026FIFAWorldCup/platform/backend/app/main.py
OG T bd2fb5cc33
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m27s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 6m20s
feat: refresh recommendation calibration from settled performance
2026-06-19 00:14:07 +08:00

4289 lines
156 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
recalibrate_daily_card_confidence_payload,
update_runtime_market_calibration,
)
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 divoire': (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:
return {
'status': 'standby',
'status_label': '新聞快照尚未接入正式授權來源',
'generated_at': datetime.now(timezone.utc).isoformat(),
'items': [],
'news': [],
'source': 'news_worker',
'message': '目前沒有可公開引用的授權新聞快照;系統不產生假新聞,只回報資料新鮮度。',
'cache_status': 'missing',
}
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,
}
team_pair_key = tuple(
sorted(
(
" ".join(home_label.strip().lower().split()),
" ".join(away_label.strip().lower().split()),
)
)
)
kickoff_key = (
kickoff_utc.replace(minute=0, second=0, microsecond=0).isoformat()
if hasattr(kickoff_utc, 'isoformat') and hasattr(kickoff_utc, 'minute')
else str(kickoff_utc)[:13]
)
key = (*team_pair_key, kickoff_key)
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 = recalibrate_daily_card_confidence_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
RECOMMENDATION_CALIBRATION_CACHE: dict[str, Any] = {
'expires_at': None,
'payload': None,
}
RECOMMENDATION_CALIBRATION_TTL_SECONDS = 600
def _bucket_value(bucket: Any, key: str, default: Any = 0) -> Any:
if isinstance(bucket, dict):
return bucket.get(key, default)
return getattr(bucket, key, default)
def _build_runtime_market_calibration(buckets: list[Any], days_back: int) -> dict[str, dict[str, Any]]:
calibration: dict[str, dict[str, Any]] = {}
for bucket in buckets:
market_type = str(_bucket_value(bucket, 'market_type', '') or '').strip()
if not market_type:
continue
settled_count = int(_bucket_value(bucket, 'settled_count', 0) or 0)
hit_count = int(_bucket_value(bucket, 'hit_count', 0) or 0)
miss_count = int(_bucket_value(bucket, 'miss_count', 0) or 0)
denominator = hit_count + miss_count
if denominator <= 0:
continue
hit_rate = round((hit_count / denominator) * 100, 2)
if hit_rate < 20.0 and settled_count >= 4:
severity = 'severe'
confidence_penalty = 10.0
stake_multiplier = 0.55
min_ev_boost = 5.0
min_win_prob_boost = 0.04
action_note = '先降為保守監控,不當核心下注'
elif hit_rate < 45.0 and settled_count >= 4:
severity = 'caution'
confidence_penalty = round(min(8.0, 3.0 + ((45.0 - hit_rate) / 4.0)), 1)
stake_multiplier = 0.70
min_ev_boost = 3.0
min_win_prob_boost = 0.02
action_note = '提高進場門檻並縮小注碼'
elif hit_rate >= 65.0 and settled_count >= 5:
severity = 'stable'
confidence_penalty = 0.0
stake_multiplier = 1.0
min_ev_boost = 0.0
min_win_prob_boost = 0.0
action_note = '暫列較穩玩法,但不自動加碼'
else:
continue
if market_type == '正確比分':
confidence_penalty = max(confidence_penalty, 11.0)
stake_multiplier = min(stake_multiplier, 0.42)
min_ev_boost = max(min_ev_boost, 8.0)
min_win_prob_boost = max(min_win_prob_boost, 0.03)
elif market_type in {'跨場串關', '同場串關'}:
confidence_penalty = max(confidence_penalty, 6.5)
stake_multiplier = min(stake_multiplier, 0.68)
min_ev_boost = max(min_ev_boost, 4.0)
min_win_prob_boost = max(min_win_prob_boost, 0.02)
elif market_type in {'勝平負', '大小球 2.5'} and severity == 'severe':
confidence_penalty = max(confidence_penalty, 9.0)
stake_multiplier = min(stake_multiplier, 0.58)
min_ev_boost = max(min_ev_boost, 5.0)
min_win_prob_boost = max(min_win_prob_boost, 0.035)
calibration[market_type] = {
'settled_count': settled_count,
'hit_rate_percent': hit_rate,
'confidence_penalty': confidence_penalty,
'stake_multiplier': stake_multiplier,
'min_ev_boost': min_ev_boost,
'min_win_prob_boost': min_win_prob_boost,
'severity': severity,
'note': (
f'{days_back}{market_type} {settled_count} 筆可判定、'
f'命中率 {hit_rate:.2f}%,系統已自動{action_note}'
),
}
return calibration
async def _refresh_runtime_recommendation_calibration(days_back: int = 7) -> dict[str, dict[str, Any]]:
now = datetime.now(timezone.utc)
expires_at = RECOMMENDATION_CALIBRATION_CACHE.get('expires_at')
cached_payload = RECOMMENDATION_CALIBRATION_CACHE.get('payload')
if isinstance(expires_at, datetime) and expires_at > now and isinstance(cached_payload, dict):
update_runtime_market_calibration(cached_payload)
return cached_payload
performance = await _build_recommendation_performance(days_back)
calibration = _build_runtime_market_calibration(list(performance.by_market_type), days_back)
update_runtime_market_calibration(calibration)
RECOMMENDATION_CALIBRATION_CACHE['payload'] = calibration
RECOMMENDATION_CALIBRATION_CACHE['expires_at'] = now + timedelta(seconds=RECOMMENDATION_CALIBRATION_TTL_SECONDS)
return calibration
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}%。'
)
runtime_calibration = _build_runtime_market_calibration(buckets, days_back)
update_runtime_market_calibration(runtime_calibration)
RECOMMENDATION_CALIBRATION_CACHE['payload'] = runtime_calibration
RECOMMENDATION_CALIBRATION_CACHE['expires_at'] = datetime.now(timezone.utc) + timedelta(
seconds=RECOMMENDATION_CALIBRATION_TTL_SECONDS
)
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
effective_end = end or (max(match_counts.keys()) if match_counts else start)
calendar_days: list[str] = []
cursor_day = _to_date(start)
final_day = _to_date(effective_end)
while cursor_day <= final_day:
calendar_days.append(cursor_day.isoformat())
cursor_day += timedelta(days=1)
dates: list[dict[str, Any]] = []
for target_date in calendar_days:
target_day = _to_date(target_date)
if match_counts[target_date] <= 0:
dates.append(
{
'date': target_date,
'match_count': 0,
'matched_matches': 0,
'recommendation_count': 0,
'live_count': 0,
'watch_count': 0,
'safe_single_count': 0,
'high_risk_single_count': 0,
'safe_parlay_count': 0,
'sgp_lottery_count': 0,
'total_amount_twd': 0,
'market_data_status': 'no_matches',
'snapshot_status': 'not_applicable',
'snapshot_item_count': 0,
'snapshot_preserved_count': 0,
'summary': '台北時間當日沒有世界盃賽事;保留此日期是為了讓賽程從開踢日起完整呈現。',
}
)
continue
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': effective_end,
'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)
try:
await _refresh_runtime_recommendation_calibration(7)
except Exception:
pass
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/recommendation-calibration')
async def recommendation_calibration_route(days_back: int = 7) -> dict[str, Any]:
normalized_days_back = max(1, min(days_back, 30))
calibration = await _refresh_runtime_recommendation_calibration(normalized_days_back)
return {
'generated_at': datetime.now(timezone.utc).isoformat(),
'days_back': normalized_days_back,
'market_count': len(calibration),
'cache_ttl_seconds': RECOMMENDATION_CALIBRATION_TTL_SECONDS,
'calibration': calibration,
'methodology_note': (
'系統會用近端已完賽推薦績效,把低命中玩法提高 EV/勝率門檻並縮小新台幣上限;'
'高命中玩法只標示較穩,不會因短期樣本直接加碼。'
),
}
@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')))