4362 lines
160 KiB
Python
4362 lines
160 KiB
Python
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, text
|
||
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 d’ivoire': (28, 1680),
|
||
'ivory coast': (28, 1680),
|
||
'tunisia': (34, 1625),
|
||
'curacao': (86, 1450),
|
||
'curaçao': (86, 1450),
|
||
}
|
||
|
||
|
||
class PlayerPropsRequest(BaseModel):
|
||
player_id: str = Field(..., min_length=1, description='球員唯一識別碼')
|
||
player_name: str | None = None
|
||
metric: PropMetric
|
||
baseline_mean: float = Field(..., gt=0)
|
||
line: float = Field(..., gt=0)
|
||
match_minutes: int = Field(default=90, ge=1, le=130)
|
||
team_attack_factor: float = Field(default=1.0, ge=0.2, le=3.0)
|
||
opponent_defence_factor: float = Field(default=1.0, ge=0.2, le=3.0)
|
||
weather_fatigue_factor: float = Field(default=1.0, ge=0.2, le=2.0)
|
||
bookmaker_over_odds: float | None = Field(default=None, gt=1)
|
||
simulations: int = Field(default=10000, ge=100, le=200000)
|
||
|
||
|
||
class PlayerPropsResponse(BaseModel):
|
||
metric: str
|
||
line: float
|
||
over_probability: float
|
||
under_probability: float
|
||
expected_count: float
|
||
p5: float
|
||
p50: float
|
||
p95: float
|
||
simulation_runs: int
|
||
edge: float | None = None
|
||
top_edge: bool = False
|
||
bookmaker_over_odds: float | None = None
|
||
implied_prob: float | None = None
|
||
|
||
|
||
class KellyRequest(BaseModel):
|
||
odds: float = Field(..., gt=1)
|
||
true_prob: float = Field(..., ge=0, le=1)
|
||
bankroll: float = Field(..., gt=0)
|
||
fractional_kelly_factor: float = Field(default=1.0, ge=0, le=5)
|
||
risk_tolerance_factor: float = Field(default=1.0, ge=0, le=2)
|
||
|
||
|
||
class KellyResponse(BaseModel):
|
||
odds: float
|
||
true_prob: float
|
||
bankroll: float
|
||
raw_kelly_fraction: float
|
||
fractional_kelly_factor: float
|
||
risk_tolerance_factor: float
|
||
recommended_fraction: float
|
||
recommended_stake: float
|
||
|
||
|
||
class BacktestTrade(BaseModel):
|
||
trade_id: str = Field(..., min_length=1)
|
||
settled_at: datetime
|
||
odds: float = Field(..., gt=1)
|
||
is_win: bool
|
||
stake: float = Field(default=100.0, gt=0)
|
||
altitude_meters: int | None = None
|
||
handicap: float | None = None
|
||
weather: str | None = None
|
||
recent_form_win_rate: float | None = Field(default=None, ge=0, le=1)
|
||
market_type: str = '1x2'
|
||
selection: str = 'home'
|
||
|
||
|
||
class BacktestFilter(BaseModel):
|
||
weather: str | None = None
|
||
altitude_min_meters: int | None = None
|
||
altitude_max_meters: int | None = None
|
||
handicap_min: float | None = None
|
||
handicap_max: float | None = None
|
||
recent_win_rate_min: float | None = Field(default=None, ge=0, le=1)
|
||
recent_win_rate_max: float | None = Field(default=None, ge=0, le=1)
|
||
market_types: list[str] | None = None
|
||
start_at: datetime | None = None
|
||
end_at: datetime | None = None
|
||
|
||
|
||
class BacktestRequest(BaseModel):
|
||
initial_capital: float = Field(default=10000, gt=0)
|
||
strategy: BacktestFilter = Field(default_factory=BacktestFilter)
|
||
historical_trades: list[BacktestTrade]
|
||
|
||
|
||
class BacktestPoint(BaseModel):
|
||
ts: str
|
||
capital: float
|
||
|
||
|
||
class BacktestResponse(BaseModel):
|
||
matched: int
|
||
total: int
|
||
hit_count: int
|
||
win_rate: float
|
||
final_capital: float
|
||
net_profit: float
|
||
roi_percent: float
|
||
max_drawdown_percent: float
|
||
equity_curve: list[BacktestPoint]
|
||
|
||
|
||
class MlTrainingRow(BaseModel):
|
||
home_rest_days: float = Field(..., description='主隊休息天數')
|
||
away_rest_days: float = Field(..., description='客隊休息天數')
|
||
home_travel_distance_km: float = Field(..., description='主隊旅行距離(km)')
|
||
away_travel_distance_km: float = Field(..., description='客隊旅行距離(km)')
|
||
recent_5_xg_home: float = Field(..., description='主隊近5場 xG')
|
||
recent_5_xg_away: float = Field(..., description='客隊近5場 xG')
|
||
match_result: str = Field(..., description='home / draw / away')
|
||
|
||
|
||
class MlTrainRequest(BaseModel):
|
||
model_id: str | None = None
|
||
rows: list[MlTrainingRow]
|
||
|
||
|
||
class MlTrainResponse(BaseModel):
|
||
model_id: str
|
||
status: str
|
||
training_size: int
|
||
is_fallback: bool
|
||
accuracy: float | None
|
||
|
||
|
||
class MlEdgeRequest(BaseModel):
|
||
model_id: str | None = 'default'
|
||
match_id: str = Field(...)
|
||
home_rest_days: float
|
||
away_rest_days: float
|
||
home_travel_distance_km: float
|
||
away_travel_distance_km: float
|
||
recent_5_xg_home: float
|
||
recent_5_xg_away: float
|
||
home_implied_odds: float = Field(..., gt=1)
|
||
draw_implied_odds: float = Field(..., gt=1)
|
||
away_implied_odds: float = Field(..., gt=1)
|
||
|
||
|
||
class MlModelEdge(BaseModel):
|
||
model_prob: float
|
||
implied_prob: float
|
||
edge: float
|
||
strong_buy: bool
|
||
|
||
|
||
class MlEdgeResponse(BaseModel):
|
||
match_id: str
|
||
model_id: str
|
||
is_fallback_model: bool
|
||
model_probs: dict[str, float]
|
||
edges: dict[str, MlModelEdge]
|
||
strong_buy: bool
|
||
strongest_outcome: str
|
||
strongest_edge_percent: float
|
||
feature_columns: list[str]
|
||
training_size: int
|
||
|
||
|
||
class MatchConditionRequest(BaseModel):
|
||
match_id: str = Field(...)
|
||
avg_yellow_cards: float
|
||
penalties_per_game: float
|
||
cards_ou_line: float
|
||
temp_c: float
|
||
humidity_pct: float
|
||
venue_altitude_meters: int = Field(..., ge=0)
|
||
home_second_half_attack: float = Field(..., gt=0)
|
||
away_second_half_attack: float = Field(..., gt=0)
|
||
|
||
|
||
class MatchConditionResponse(BaseModel):
|
||
match_id: str
|
||
strictness_index: float
|
||
heat_index: float
|
||
cards_pressure_alert: bool
|
||
second_half_home_attack: float
|
||
second_half_away_attack: float
|
||
second_half_under_recommendation: bool
|
||
attacker_direction: str
|
||
|
||
|
||
class RlmRequest(BaseModel):
|
||
match_id: str = Field(...)
|
||
market_type: str = '1x2'
|
||
selection: str = 'home'
|
||
ticket_threshold: float = Field(default=80, ge=0, le=100)
|
||
odds_change_threshold: float = Field(default=0.05, ge=0, le=1)
|
||
|
||
|
||
class RlmAlertResponse(BaseModel):
|
||
match_id: str
|
||
market_type: str
|
||
selection: str
|
||
opening_odds: float
|
||
current_odds: float
|
||
ticket_pct: float
|
||
handle_pct: float
|
||
odds_change_pct: float
|
||
smart_money_to: str
|
||
is_triggered: bool
|
||
rationale: str
|
||
|
||
|
||
class RlmResponse(BaseModel):
|
||
alerts: list[RlmAlertResponse]
|
||
total: int
|
||
|
||
|
||
class ProofOfYieldSettleItem(BaseModel):
|
||
recommendation_id: str | None = None
|
||
match_id: str
|
||
market_type: str = '1x2'
|
||
selection: str = 'home'
|
||
stake: float = Field(..., gt=0)
|
||
recommended_odds: float = Field(..., gt=1)
|
||
closing_odds: float = Field(..., gt=0)
|
||
is_win: bool
|
||
settled_at: str | None = None
|
||
|
||
|
||
class ProofOfYieldSettleRequest(BaseModel):
|
||
items: list[ProofOfYieldSettleItem]
|
||
|
||
|
||
class ProofOfYieldRecordResponse(BaseModel):
|
||
recommendation_id: str
|
||
match_id: str
|
||
market_type: str
|
||
selection: str
|
||
stake: float
|
||
recommended_odds: float
|
||
closing_odds: float
|
||
is_win: bool
|
||
settled_at: str
|
||
clv_percent: float | None
|
||
pnl: float
|
||
|
||
|
||
class ProofOfYieldSummaryResponse(BaseModel):
|
||
total_recommendations: int
|
||
hit_count: int
|
||
win_rate_percent: float
|
||
total_stake: float
|
||
total_pnl: float
|
||
roi_percent: float
|
||
avg_clv_percent: float
|
||
|
||
|
||
class ProofOfYieldLedgerResponse(BaseModel):
|
||
summary: ProofOfYieldSummaryResponse
|
||
records: list[ProofOfYieldRecordResponse]
|
||
|
||
|
||
class UserBet(BaseModel):
|
||
market_type: str = Field(default='unknown', min_length=1)
|
||
parlay_type: str | None = None
|
||
odds: float | None = Field(default=None, gt=1)
|
||
stake: float = Field(..., gt=0)
|
||
recommended_odds: float | None = Field(default=None, gt=1)
|
||
closing_odds: float | None = Field(default=None, gt=0)
|
||
is_settled: bool = True
|
||
is_win: bool = False
|
||
match_stage: str | None = None
|
||
stage: str | None = None
|
||
|
||
|
||
class PortfolioLeaksRequest(BaseModel):
|
||
user_bets: list[UserBet]
|
||
|
||
|
||
class PortfolioLeakCluster(BaseModel):
|
||
market_type: str
|
||
bet_type: str
|
||
odds_bucket: str
|
||
match_stage: str
|
||
bet_count: int
|
||
total_stake: float
|
||
closed_count: int
|
||
win_count: int
|
||
total_pnl: float
|
||
avg_clv_percent: float
|
||
roi_percent: float
|
||
hit_rate_percent: float
|
||
status: str
|
||
|
||
|
||
class PortfolioHardTruth(BaseModel):
|
||
title: str
|
||
message: str
|
||
cluster: dict[str, str | int | float]
|
||
|
||
|
||
class PortfolioLeaksResponse(BaseModel):
|
||
total_bet_count: int
|
||
settled_bet_count: int
|
||
total_stake: float
|
||
total_pnl: float
|
||
overall_roi_percent: float
|
||
overall_hit_rate_percent: float
|
||
clusters: list[PortfolioLeakCluster]
|
||
hard_truths: list[PortfolioHardTruth]
|
||
|
||
|
||
class HedgeRequest(BaseModel):
|
||
original_stake: float = Field(..., gt=0)
|
||
parlay_total_odds: float = Field(..., gt=1)
|
||
final_leg_hedge_odds: float = Field(..., gt=1)
|
||
|
||
|
||
class HedgeResponse(BaseModel):
|
||
hedge_stake: float
|
||
locked_profit: float
|
||
parlay_net_after_hedge_if_win: float
|
||
hedge_net_if_win: float
|
||
|
||
|
||
class DailyCardLeg(BaseModel):
|
||
match_id: str
|
||
selection: str
|
||
odds: float = Field(..., gt=1)
|
||
market_type: str | None = None
|
||
has_market_odds: bool | None = None
|
||
odds_source_label: str | None = None
|
||
|
||
|
||
class DailyCardItem(BaseModel):
|
||
match_id: str
|
||
match_label: str
|
||
market_type: str
|
||
selection: str
|
||
target_odds: float = Field(..., gt=1)
|
||
win_prob: float
|
||
ev_percent: float
|
||
stake_units: float = Field(..., ge=0)
|
||
stake_amount_twd: int | None = None
|
||
unit_size_twd: int | None = None
|
||
recommendation: str
|
||
rationale: str
|
||
confidence_score: float | None = None
|
||
confidence_band: str | None = None
|
||
confidence_factors: list[str] | None = None
|
||
data_quality: str | None = None
|
||
has_market_odds: bool | None = None
|
||
odds_source_label: str | None = None
|
||
odds_source_kind: str | None = None
|
||
risk_level: str | None = None
|
||
market_implied_prob: float | None = None
|
||
edge_percent: float | None = None
|
||
data_checks: list[str] | None = None
|
||
legs: list[DailyCardLeg] | None = None
|
||
sgp_price_status: str | None = None
|
||
|
||
|
||
class DailyCardResponse(BaseModel):
|
||
date: str
|
||
total_daily_unit_recommendation: float
|
||
total_daily_amount_twd: int | None = None
|
||
unit_size_twd: int | None = None
|
||
summary: str
|
||
market_data_status: str | None = None
|
||
data_quality_summary: dict[str, int] = Field(default_factory=dict)
|
||
execution_policy: str | None = None
|
||
auto_refresh_seconds: int = 60
|
||
safe_singles: list[DailyCardItem]
|
||
high_risk_singles: list[DailyCardItem]
|
||
safe_parlays: list[DailyCardItem]
|
||
sgp_lotteries: list[DailyCardItem]
|
||
matched_matches: int
|
||
raw_match_count: int | None = None
|
||
merged_duplicate_match_count: int | None = None
|
||
dedupe_notes: list[str] | None = None
|
||
stage_distribution: dict[str, int]
|
||
|
||
|
||
class RecommendationPerformanceItem(BaseModel):
|
||
match_id: str
|
||
match_label: str
|
||
market_type: str
|
||
selection: str
|
||
recommendation: str
|
||
result_score: str
|
||
outcome: str
|
||
outcome_label: str
|
||
target_odds: float
|
||
win_prob: float
|
||
ev_percent: float
|
||
stake_units: float
|
||
stake_amount_twd: int | None = None
|
||
confidence_score: float | None = None
|
||
confidence_band: str | None = None
|
||
has_market_odds: bool | None = None
|
||
odds_source_label: str | None = None
|
||
odds_source_kind: str | None = None
|
||
lesson: str
|
||
|
||
|
||
class RecommendationPerformanceBucket(BaseModel):
|
||
market_type: str
|
||
recommendation_count: int
|
||
settled_count: int
|
||
hit_count: int
|
||
miss_count: int
|
||
push_count: int
|
||
hit_rate_percent: float
|
||
|
||
|
||
class RecommendationPerformanceSourceBucket(BaseModel):
|
||
source_label: str
|
||
source_kind: str
|
||
recommendation_count: int
|
||
settled_count: int
|
||
hit_count: int
|
||
miss_count: int
|
||
push_count: int
|
||
hit_rate_percent: float
|
||
|
||
|
||
class RecommendationPerformanceResponse(BaseModel):
|
||
generated_at: str
|
||
days_back: int
|
||
finished_match_count: int
|
||
rebuilt_recommendation_count: int
|
||
settled_recommendation_count: int
|
||
hit_count: int
|
||
miss_count: int
|
||
push_count: int
|
||
hit_rate_percent: float
|
||
summary: str
|
||
methodology_note: str
|
||
improvement_actions: list[str]
|
||
by_market_type: list[RecommendationPerformanceBucket]
|
||
by_odds_source: list[RecommendationPerformanceSourceBucket]
|
||
items: list[RecommendationPerformanceItem]
|
||
|
||
|
||
class AgentVerificationCheck(BaseModel):
|
||
agent: str
|
||
role: str
|
||
status: str
|
||
status_label: str
|
||
evidence: list[str]
|
||
next_action: str | None = None
|
||
last_checked_at: str
|
||
|
||
|
||
class AgentVerificationResponse(BaseModel):
|
||
generated_at: str
|
||
overall_status: str
|
||
overall_label: str
|
||
production_ready: bool
|
||
decision_policy: str
|
||
calibration_summary: dict[str, Any] = Field(default_factory=dict)
|
||
checks: list[AgentVerificationCheck]
|
||
|
||
|
||
class GeminiUsageResponse(BaseModel):
|
||
generated_at: str
|
||
month: str
|
||
status: str
|
||
status_label: str
|
||
paused: bool
|
||
cap_usd: float
|
||
estimated_cost_usd: float
|
||
remaining_usd: float
|
||
request_count: int
|
||
input_tokens: int
|
||
output_tokens: int
|
||
grounded_query_count: int
|
||
pricing_note: str
|
||
next_action: str | None = None
|
||
|
||
|
||
class AgentDailyReviewResponse(BaseModel):
|
||
generated_at: str
|
||
date: str
|
||
status: str
|
||
status_label: str
|
||
model: str
|
||
reviewed_count: int
|
||
summary: str
|
||
raw_response: str | None = None
|
||
guardrails: list[str]
|
||
|
||
|
||
class MatchListItem(BaseModel):
|
||
match_id: str
|
||
home_team: str
|
||
away_team: str
|
||
home_score: int | None = None
|
||
away_score: int | None = None
|
||
kickoff_utc: datetime
|
||
status: str
|
||
venue_name: str | None = None
|
||
venue_city: str | None = None
|
||
venue_country: str | None = None
|
||
|
||
|
||
class MatchOddsPoint(BaseModel):
|
||
recorded_at: str
|
||
bookmaker: str
|
||
bookmaker_id: str
|
||
market_type: str
|
||
selection: str
|
||
decimal_odds: float
|
||
implied_probability: float
|
||
|
||
|
||
class MatchPoissonOutput(BaseModel):
|
||
expected_home_goals: float
|
||
expected_away_goals: float
|
||
score_matrix: list[list[float]]
|
||
one_x_two: dict[str, float]
|
||
over_under_2_5: dict[str, float]
|
||
|
||
|
||
class MatchConditionReadout(BaseModel):
|
||
strictness_index: float
|
||
heat_index: float
|
||
cards_pressure_alert: bool
|
||
second_half_home_attack: float
|
||
second_half_away_attack: float
|
||
second_half_under_recommendation: bool
|
||
attacker_direction: str
|
||
|
||
|
||
class MatchDetailResponse(BaseModel):
|
||
match_id: str
|
||
home_team: str
|
||
away_team: str
|
||
home_score: int | None = None
|
||
away_score: int | None = None
|
||
home_xg: float
|
||
away_xg: float
|
||
match_time_utc: str
|
||
status: str
|
||
venue_name: str
|
||
venue_city: str
|
||
venue_country: str
|
||
venue_altitude_meters: int | None
|
||
odds_series: list[MatchOddsPoint]
|
||
odds_quality: str = 'missing_market'
|
||
xg_quality: str = 'observed'
|
||
poisson: MatchPoissonOutput
|
||
conditions: MatchConditionReadout
|
||
quant_summary: str
|
||
|
||
|
||
class SourceHealthResponse(BaseModel):
|
||
status: str
|
||
odds_coverage_status: str = 'unknown'
|
||
formal_provider_status: dict[str, Any] = Field(default_factory=dict)
|
||
formal_provider_blocker: str | None = None
|
||
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')
|
||
primary_provider_status = (ingestion_status or {}).get('primary_provider')
|
||
if not isinstance(primary_provider_status, dict):
|
||
primary_provider_status = {
|
||
'provider': 'the-odds-api',
|
||
'status': 'unknown',
|
||
'api_key_configured': False,
|
||
'api_key_placeholder': False,
|
||
'message': '尚未收到 odds worker 的正式 provider 狀態回報。',
|
||
}
|
||
primary_provider_state = str(primary_provider_status.get('status') or 'unknown')
|
||
if primary_provider_state == 'ok':
|
||
formal_provider_blocker = None
|
||
elif primary_provider_state == 'placeholder_key':
|
||
formal_provider_blocker = 'THE_ODDS_API_KEY 仍是 placeholder,請改用有效正式 key 後才可升級多來源盤口。'
|
||
elif primary_provider_state == 'missing_key':
|
||
formal_provider_blocker = 'THE_ODDS_API_KEY 尚未設定,無法抓取正式多來源盤口。'
|
||
elif primary_provider_state == 'empty_events':
|
||
formal_provider_blocker = 'The Odds API 可呼叫,但目前沒有回傳可用世界盃盤口;暫以台灣參考盤監控。'
|
||
elif primary_provider_state == 'error':
|
||
formal_provider_blocker = str(primary_provider_status.get('message') or 'The Odds API 最近一次抓取失敗。')
|
||
else:
|
||
formal_provider_blocker = '尚未確認 The Odds API 正式 provider 狀態。'
|
||
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,
|
||
formal_provider_status=primary_provider_status,
|
||
formal_provider_blocker=formal_provider_blocker,
|
||
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'],
|
||
'formal_provider_status': primary_provider_status,
|
||
'formal_provider_blocker': formal_provider_blocker,
|
||
'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': formal_provider_blocker or '已接入正式 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')
|
||
primary_provider_status = (ingestion_status or {}).get('primary_provider')
|
||
if not isinstance(primary_provider_status, dict):
|
||
primary_provider_status = {}
|
||
primary_provider_state = str(primary_provider_status.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 primary_provider_state == 'placeholder_key':
|
||
blocking_reasons.append('THE_ODDS_API_KEY 仍是 placeholder,尚未接入有效正式 odds provider。')
|
||
elif primary_provider_state == 'missing_key':
|
||
blocking_reasons.append('THE_ODDS_API_KEY 尚未設定,無法抓取正式多來源盤口。')
|
||
elif primary_provider_state == 'empty_events':
|
||
blocking_reasons.append('The Odds API 已可呼叫,但目前沒有回傳可用世界盃盤口;只能暫時使用參考盤監控。')
|
||
elif primary_provider_state == 'error':
|
||
blocking_reasons.append(str(primary_provider_status.get('message') or 'The Odds API 最近一次抓取失敗。'))
|
||
elif '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,
|
||
'formal_provider_status': primary_provider_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()
|
||
|
||
|
||
async def ensure_daily_recommendation_snapshot_schema() -> None:
|
||
"""確保每日作戰室快照表存在,避免正式資料庫漏 migration 時造成推薦 API 500。"""
|
||
|
||
async with SessionFactory() as session:
|
||
await session.execute(
|
||
text(
|
||
'''
|
||
CREATE TABLE IF NOT EXISTS daily_recommendation_snapshots (
|
||
id VARCHAR(64) PRIMARY KEY,
|
||
target_date VARCHAR(10) NOT NULL,
|
||
generated_at TIMESTAMPTZ NOT NULL,
|
||
items_count INTEGER NOT NULL DEFAULT 0,
|
||
live_market_count INTEGER NOT NULL DEFAULT 0,
|
||
pre_market_count INTEGER NOT NULL DEFAULT 0,
|
||
payload JSONB NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
)
|
||
'''
|
||
)
|
||
)
|
||
await session.execute(
|
||
text(
|
||
'CREATE INDEX IF NOT EXISTS idx_daily_recommendation_snapshots_target_date '
|
||
'ON daily_recommendation_snapshots (target_date)'
|
||
)
|
||
)
|
||
await session.commit()
|
||
logger.info('Daily recommendation snapshot schema bootstrap completed.')
|
||
|
||
|
||
@app.on_event('startup')
|
||
async def on_startup() -> None:
|
||
await ensure_daily_recommendation_snapshot_schema()
|
||
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')))
|