feat: refresh recommendation calibration from settled performance
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m27s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 6m20s

This commit is contained in:
OG T
2026-06-19 00:14:07 +08:00
parent d9694b7dff
commit bd2fb5cc33
2 changed files with 158 additions and 4 deletions

View File

@@ -135,6 +135,27 @@ RECENT_MARKET_CALIBRATION: dict[str, dict[str, Any]] = {
}, },
} }
_RUNTIME_MARKET_CALIBRATION: dict[str, dict[str, Any]] | None = None
def update_runtime_market_calibration(market_calibration: dict[str, dict[str, Any]] | None) -> None:
"""Update in-process market calibration from settled recommendation performance."""
global _RUNTIME_MARKET_CALIBRATION
if not market_calibration:
_RUNTIME_MARKET_CALIBRATION = None
return
cleaned: dict[str, dict[str, Any]] = {}
for market_type, calibration in market_calibration.items():
if not isinstance(calibration, dict):
continue
normalized_market = str(market_type or '').strip()
if not normalized_market:
continue
cleaned[normalized_market] = dict(calibration)
_RUNTIME_MARKET_CALIBRATION = cleaned or None
def _safe_float(value: Any, default: float = 0.0) -> float: def _safe_float(value: Any, default: float = 0.0) -> float:
try: try:
@@ -546,10 +567,11 @@ def _recent_market_calibration(market_type: str) -> dict[str, Any] | None:
"""Return recent post-match market calibration for public recommendation safety.""" """Return recent post-match market calibration for public recommendation safety."""
normalized_market = str(market_type or '').strip() normalized_market = str(market_type or '').strip()
if normalized_market in RECENT_MARKET_CALIBRATION: calibration_source = _RUNTIME_MARKET_CALIBRATION or RECENT_MARKET_CALIBRATION
return RECENT_MARKET_CALIBRATION[normalized_market] if normalized_market in calibration_source:
return calibration_source[normalized_market]
if normalized_market.startswith('大小球 '): if normalized_market.startswith('大小球 '):
return RECENT_MARKET_CALIBRATION.get(normalized_market) return calibration_source.get(normalized_market)
return None return None

View File

@@ -20,7 +20,11 @@ from pydantic import BaseModel, Field
from redis.asyncio import Redis from redis.asyncio import Redis
from .db.base import SessionFactory from .db.base import SessionFactory
from .db.models import Bookmaker, DailyRecommendationSnapshot, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue from .db.models import Bookmaker, DailyRecommendationSnapshot, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue
from .analytics.daily_card_generator import generate_daily_card, recalibrate_daily_card_confidence_payload from .analytics.daily_card_generator import (
generate_daily_card,
recalibrate_daily_card_confidence_payload,
update_runtime_market_calibration,
)
from .analytics.localization import ( from .analytics.localization import (
localize_city, localize_city,
localize_country, localize_country,
@@ -2229,6 +2233,107 @@ def _performance_actions(hit_rate: float, buckets: list[dict[str, Any]], items:
return actions return actions
RECOMMENDATION_CALIBRATION_CACHE: dict[str, Any] = {
'expires_at': None,
'payload': None,
}
RECOMMENDATION_CALIBRATION_TTL_SECONDS = 600
def _bucket_value(bucket: Any, key: str, default: Any = 0) -> Any:
if isinstance(bucket, dict):
return bucket.get(key, default)
return getattr(bucket, key, default)
def _build_runtime_market_calibration(buckets: list[Any], days_back: int) -> dict[str, dict[str, Any]]:
calibration: dict[str, dict[str, Any]] = {}
for bucket in buckets:
market_type = str(_bucket_value(bucket, 'market_type', '') or '').strip()
if not market_type:
continue
settled_count = int(_bucket_value(bucket, 'settled_count', 0) or 0)
hit_count = int(_bucket_value(bucket, 'hit_count', 0) or 0)
miss_count = int(_bucket_value(bucket, 'miss_count', 0) or 0)
denominator = hit_count + miss_count
if denominator <= 0:
continue
hit_rate = round((hit_count / denominator) * 100, 2)
if hit_rate < 20.0 and settled_count >= 4:
severity = 'severe'
confidence_penalty = 10.0
stake_multiplier = 0.55
min_ev_boost = 5.0
min_win_prob_boost = 0.04
action_note = '先降為保守監控,不當核心下注'
elif hit_rate < 45.0 and settled_count >= 4:
severity = 'caution'
confidence_penalty = round(min(8.0, 3.0 + ((45.0 - hit_rate) / 4.0)), 1)
stake_multiplier = 0.70
min_ev_boost = 3.0
min_win_prob_boost = 0.02
action_note = '提高進場門檻並縮小注碼'
elif hit_rate >= 65.0 and settled_count >= 5:
severity = 'stable'
confidence_penalty = 0.0
stake_multiplier = 1.0
min_ev_boost = 0.0
min_win_prob_boost = 0.0
action_note = '暫列較穩玩法,但不自動加碼'
else:
continue
if market_type == '正確比分':
confidence_penalty = max(confidence_penalty, 11.0)
stake_multiplier = min(stake_multiplier, 0.42)
min_ev_boost = max(min_ev_boost, 8.0)
min_win_prob_boost = max(min_win_prob_boost, 0.03)
elif market_type in {'跨場串關', '同場串關'}:
confidence_penalty = max(confidence_penalty, 6.5)
stake_multiplier = min(stake_multiplier, 0.68)
min_ev_boost = max(min_ev_boost, 4.0)
min_win_prob_boost = max(min_win_prob_boost, 0.02)
elif market_type in {'勝平負', '大小球 2.5'} and severity == 'severe':
confidence_penalty = max(confidence_penalty, 9.0)
stake_multiplier = min(stake_multiplier, 0.58)
min_ev_boost = max(min_ev_boost, 5.0)
min_win_prob_boost = max(min_win_prob_boost, 0.035)
calibration[market_type] = {
'settled_count': settled_count,
'hit_rate_percent': hit_rate,
'confidence_penalty': confidence_penalty,
'stake_multiplier': stake_multiplier,
'min_ev_boost': min_ev_boost,
'min_win_prob_boost': min_win_prob_boost,
'severity': severity,
'note': (
f'{days_back}{market_type} {settled_count} 筆可判定、'
f'命中率 {hit_rate:.2f}%,系統已自動{action_note}'
),
}
return calibration
async def _refresh_runtime_recommendation_calibration(days_back: int = 7) -> dict[str, dict[str, Any]]:
now = datetime.now(timezone.utc)
expires_at = RECOMMENDATION_CALIBRATION_CACHE.get('expires_at')
cached_payload = RECOMMENDATION_CALIBRATION_CACHE.get('payload')
if isinstance(expires_at, datetime) and expires_at > now and isinstance(cached_payload, dict):
update_runtime_market_calibration(cached_payload)
return cached_payload
performance = await _build_recommendation_performance(days_back)
calibration = _build_runtime_market_calibration(list(performance.by_market_type), days_back)
update_runtime_market_calibration(calibration)
RECOMMENDATION_CALIBRATION_CACHE['payload'] = calibration
RECOMMENDATION_CALIBRATION_CACHE['expires_at'] = now + timedelta(seconds=RECOMMENDATION_CALIBRATION_TTL_SECONDS)
return calibration
async def _build_recommendation_performance(days_back: int) -> RecommendationPerformanceResponse: async def _build_recommendation_performance(days_back: int) -> RecommendationPerformanceResponse:
match_payload, result_lookup = await _query_finished_recommendation_snapshots(days_back) match_payload, result_lookup = await _query_finished_recommendation_snapshots(days_back)
generated_at = datetime.now(timezone.utc).isoformat() generated_at = datetime.now(timezone.utc).isoformat()
@@ -2351,6 +2456,12 @@ async def _build_recommendation_performance(days_back: int) -> RecommendationPer
f'其中 {settled_count} 組可自動判定,命中 {hit_count} 組、未中 {miss_count} 組、退回 {push_count} 組,' f'其中 {settled_count} 組可自動判定,命中 {hit_count} 組、未中 {miss_count} 組、退回 {push_count} 組,'
f'命中率 {hit_rate:.2f}%。' f'命中率 {hit_rate:.2f}%。'
) )
runtime_calibration = _build_runtime_market_calibration(buckets, days_back)
update_runtime_market_calibration(runtime_calibration)
RECOMMENDATION_CALIBRATION_CACHE['payload'] = runtime_calibration
RECOMMENDATION_CALIBRATION_CACHE['expires_at'] = datetime.now(timezone.utc) + timedelta(
seconds=RECOMMENDATION_CALIBRATION_TTL_SECONDS
)
return RecommendationPerformanceResponse( return RecommendationPerformanceResponse(
generated_at=generated_at, generated_at=generated_at,
@@ -4111,6 +4222,10 @@ async def daily_card_calendar_status_route() -> dict[str, Any]:
@app.get('/analytics/daily-card/{target_date}', response_model=DailyCardResponse) @app.get('/analytics/daily-card/{target_date}', response_model=DailyCardResponse)
async def generate_daily_card_route(target_date: str) -> DailyCardResponse: async def generate_daily_card_route(target_date: str) -> DailyCardResponse:
target_day = _to_date(target_date) target_day = _to_date(target_date)
try:
await _refresh_runtime_recommendation_calibration(7)
except Exception:
pass
snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) if target_day <= _taipei_today_date() else None snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) if target_day <= _taipei_today_date() else None
if target_day < _taipei_today_date() and snapshot_payload: if target_day < _taipei_today_date() and snapshot_payload:
return DailyCardResponse(**snapshot_payload) return DailyCardResponse(**snapshot_payload)
@@ -4132,6 +4247,23 @@ async def recommendation_performance_route(days_back: int = 7) -> Recommendation
return await _build_recommendation_performance(normalized_days_back) return await _build_recommendation_performance(normalized_days_back)
@app.get('/analytics/recommendation-calibration')
async def recommendation_calibration_route(days_back: int = 7) -> dict[str, Any]:
normalized_days_back = max(1, min(days_back, 30))
calibration = await _refresh_runtime_recommendation_calibration(normalized_days_back)
return {
'generated_at': datetime.now(timezone.utc).isoformat(),
'days_back': normalized_days_back,
'market_count': len(calibration),
'cache_ttl_seconds': RECOMMENDATION_CALIBRATION_TTL_SECONDS,
'calibration': calibration,
'methodology_note': (
'系統會用近端已完賽推薦績效,把低命中玩法提高 EV/勝率門檻並縮小新台幣上限;'
'高命中玩法只標示較穩,不會因短期樣本直接加碼。'
),
}
@app.get('/analytics/agent-verification', response_model=AgentVerificationResponse) @app.get('/analytics/agent-verification', response_model=AgentVerificationResponse)
async def agent_verification_route() -> AgentVerificationResponse: async def agent_verification_route() -> AgentVerificationResponse:
return await _build_agent_verification() return await _build_agent_verification()