1474 lines
44 KiB
Python
1474 lines
44 KiB
Python
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')))
|