from __future__ import annotations from dataclasses import dataclass import json from typing import Any, Dict, Mapping from redis.asyncio import Redis ARBITRAGE_LUA = r''' local odds_json = ARGV[1] local payload = cjson.decode(odds_json) local grouped = {} for _, row in ipairs(payload) do local market = row.market_type local selection = row.selection local odds = tonumber(row.decimal_odds) if market and selection and odds and odds > 0 then if grouped[market] == nil then grouped[market] = {} end if grouped[market][selection] == nil or odds > grouped[market][selection] then grouped[market][selection] = odds end end end local out = {} for market, selections in pairs(grouped) do local prob_sum = 0 local count = 0 for _, odds in pairs(selections) do prob_sum = prob_sum + (1 / odds) count = count + 1 end if count > 1 then out[market] = { has_arbitrage = (prob_sum < 1), implied_total_probability = prob_sum, edge = math.max(1 - prob_sum, 0), best_odds = selections, } end end return cjson.encode(out) ''' @dataclass(slots=True) class MatchState: """賽中 Hash 快照欄位。""" home_score: int away_score: int possession_home_pct: float possession_away_pct: float red_cards_home: int red_cards_away: int class MatchCacheManager: """賽事 Redis 快取層。 - live:{match_id}:odds 存 JSON,即時賠率 - live:{match_id}:state 存 Hash,包含比分、控球率、紅牌數 """ def __init__(self, redis: Redis) -> None: self.redis = redis self._lua_sha: str | None = None async def _ensure_lua(self) -> str: if self._lua_sha is None: self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA) return self._lua_sha async def set_match_odds( self, match_id: str, payload: list[dict[str, Any]], *, ttl_seconds: int = 45, finished: bool = False, ) -> None: key = f'live:{match_id}:odds' value = json.dumps(payload, ensure_ascii=False) ttl = 7200 if finished else ttl_seconds await self.redis.set(name=key, value=value, ex=ttl) async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]: key = f'live:{match_id}:odds' raw = await self.redis.get(key) if not raw: return [] if isinstance(raw, bytes): raw = raw.decode() return json.loads(raw) async def set_match_state( self, match_id: str, state: MatchState | Mapping[str, Any], *, ttl_seconds: int = 7200, ) -> None: key = f'live:{match_id}:state' mapping = { 'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score, 'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score, 'possession_home_pct': state['possession_home_pct'] if isinstance(state, Mapping) else state.possession_home_pct, 'possession_away_pct': state['possession_away_pct'] if isinstance(state, Mapping) else state.possession_away_pct, 'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home, 'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away, } await self.redis.hset(name=key, mapping=mapping) await self.redis.expire(key, ttl_seconds) async def get_match_state(self, match_id: str) -> dict[str, str] | None: key = f'live:{match_id}:state' result = await self.redis.hgetall(key) return {str(k): str(v) for k, v in result.items()} if result else None async def calculate_arbitrage_for_match(self, match_id: str) -> dict[str, Any]: odds = await self.get_match_odds(match_id) if not odds: return {} sha = await self._ensure_lua() result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False)) if isinstance(result, bytes): result = result.decode() if isinstance(result, str): return json.loads(result) return result