137 lines
4.2 KiB
Python
137 lines
4.2 KiB
Python
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
|