Files
2026FIFAWorldCup/platform/backend/app/main.py

1474 lines
44 KiB
Python
Raw Blame History

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