fix: calibrate betting recommendations from settled outcomes
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m25s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 1m24s

This commit is contained in:
OG T
2026-06-18 18:48:27 +08:00
parent da4a889e14
commit 7799c1e897

View File

@@ -52,6 +52,89 @@ TEAM_ALIASES = {
'drcongo': '剛果民主共和國',
}
RECENT_MARKET_CALIBRATION: dict[str, dict[str, Any]] = {
'同場串關': {
'settled_count': 10,
'hit_rate_percent': 30.0,
'confidence_penalty': 6.5,
'stake_multiplier': 0.68,
'min_ev_boost': 4.0,
'min_win_prob_boost': 0.02,
'severity': 'caution',
'note': '近 7 天同場串關 10 筆可判定、命中率 30.00%,已提高進場門檻並縮小注碼。',
},
'正確比分': {
'settled_count': 7,
'hit_rate_percent': 0.0,
'confidence_penalty': 11.0,
'stake_multiplier': 0.42,
'min_ev_boost': 8.0,
'min_win_prob_boost': 0.03,
'severity': 'severe',
'note': '近 7 天正確比分 7 筆可判定仍未命中,僅保留超高賠小倉監控。',
},
'大小球 3.5': {
'settled_count': 6,
'hit_rate_percent': 33.33,
'confidence_penalty': 5.5,
'stake_multiplier': 0.72,
'min_ev_boost': 3.0,
'min_win_prob_boost': 0.015,
'severity': 'caution',
'note': '近 7 天大小球 3.5 命中率 33.33%,進場需要更明顯的賠率優勢。',
},
'勝平負': {
'settled_count': 6,
'hit_rate_percent': 0.0,
'confidence_penalty': 10.0,
'stake_multiplier': 0.55,
'min_ev_boost': 5.0,
'min_win_prob_boost': 0.04,
'severity': 'severe',
'note': '近 7 天勝平負 6 筆可判定仍未命中,先降為保守監控,不當核心下注。',
},
'跨場串關': {
'settled_count': 5,
'hit_rate_percent': 20.0,
'confidence_penalty': 7.0,
'stake_multiplier': 0.62,
'min_ev_boost': 4.5,
'min_win_prob_boost': 0.025,
'severity': 'caution',
'note': '近 7 天跨場串關命中率 20.00%,串關只作輔助,不放大倉位。',
},
'大小球 2.5': {
'settled_count': 4,
'hit_rate_percent': 0.0,
'confidence_penalty': 9.0,
'stake_multiplier': 0.58,
'min_ev_boost': 5.0,
'min_win_prob_boost': 0.035,
'severity': 'severe',
'note': '近 7 天大小球 2.5 四筆全未命中,需等更好的盤口差距才進場。',
},
'雙重機會': {
'settled_count': 7,
'hit_rate_percent': 71.43,
'confidence_penalty': 0.0,
'stake_multiplier': 1.0,
'min_ev_boost': 0.0,
'min_win_prob_boost': 0.0,
'severity': 'stable',
'note': '近 7 天雙重機會命中率 71.43%,暫列較穩玩法,但仍不額外加碼。',
},
'大小球 1.5': {
'settled_count': 5,
'hit_rate_percent': 100.0,
'confidence_penalty': 0.0,
'stake_multiplier': 1.0,
'min_ev_boost': 0.0,
'min_win_prob_boost': 0.0,
'severity': 'stable',
'note': '近 7 天大小球 1.5 五筆全命中,暫保留原門檻;樣本仍小,不自動加碼。',
},
}
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
@@ -459,6 +542,98 @@ def _finalize_confidence_score(
return round(_clamp(adjusted, floor, ceiling), 1)
def _recent_market_calibration(market_type: str) -> dict[str, Any] | None:
"""Return recent post-match market calibration for public recommendation safety."""
normalized_market = str(market_type or '').strip()
if normalized_market in RECENT_MARKET_CALIBRATION:
return RECENT_MARKET_CALIBRATION[normalized_market]
if normalized_market.startswith('大小球 '):
return RECENT_MARKET_CALIBRATION.get(normalized_market)
return None
def _calibrated_minimums(market_type: str, min_win_prob: float, min_ev: float) -> tuple[float, float]:
calibration = _recent_market_calibration(market_type)
if not calibration:
return min_win_prob, min_ev
return (
min_win_prob + _safe_float(calibration.get('min_win_prob_boost'), 0.0),
min_ev + _safe_float(calibration.get('min_ev_boost'), 0.0),
)
def _calibration_marker(market_type: str) -> str:
return f'近7天玩法校準{market_type}'
def _apply_recent_market_calibration(item: dict[str, Any]) -> dict[str, Any]:
"""Apply settled-result feedback to a generated pick without changing its legs."""
market_type = str(item.get('market_type') or '')
calibration = _recent_market_calibration(market_type)
if not calibration:
return item
marker = _calibration_marker(market_type)
data_checks = list(item.get('data_checks') or [])
if marker in data_checks:
return item
adjusted = dict(item)
factors = list(adjusted.get('confidence_factors') or [])
note = str(calibration.get('note') or '')
settled_count = int(_safe_float(calibration.get('settled_count'), 0))
hit_rate = _safe_float(calibration.get('hit_rate_percent'), 0.0)
severity = str(calibration.get('severity') or 'neutral')
confidence_penalty = _safe_float(calibration.get('confidence_penalty'), 0.0)
stake_multiplier = _safe_float(calibration.get('stake_multiplier'), 1.0)
current_confidence = _safe_float(adjusted.get('confidence_score'), 0.0)
if confidence_penalty > 0:
adjusted['confidence_score'] = round(_clamp(current_confidence - confidence_penalty, 0.0, current_confidence), 1)
current_stake = _safe_float(adjusted.get('stake_units'), 0.0)
if current_stake > 0 and stake_multiplier < 1.0:
next_stake = round(_clamp(current_stake * stake_multiplier, 0.05, current_stake), 2)
adjusted['stake_units'] = next_stake
adjusted['stake_amount_twd'] = _stake_amount_twd(next_stake)
if severity == 'severe':
recommendation = str(adjusted.get('recommendation') or '')
if recommendation == 'SAFE_SINGLE':
adjusted['recommendation'] = 'WATCHLIST_SINGLE'
elif recommendation == 'HIGH_RISK_SINGLE':
adjusted['recommendation'] = 'WATCHLIST_HIGH_RISK'
elif recommendation == 'SAFE_PARLAY':
adjusted['recommendation'] = 'WATCHLIST_PARLAY'
if str(adjusted.get('risk_level') or '') == 'core':
adjusted['risk_level'] = 'speculative'
adjusted['confidence_band'] = _confidence_band(
_safe_float(adjusted.get('confidence_score'), 0.0),
bool(adjusted.get('has_market_odds')),
str(adjusted.get('risk_level') or 'core'),
)
if note and note not in factors:
factors.append(note)
factors.append(f'賽後校準樣本 {settled_count} 筆,命中率 {hit_rate:.2f}%')
adjusted['confidence_factors'] = factors[:9]
adjusted['data_checks'] = data_checks + [
marker,
f'近7天樣本 {settled_count}',
f'近7天命中率 {hit_rate:.2f}%',
'已套用賽後校準觀察' if severity == 'stable' else '已套用賽後校準降權',
]
rationale = str(adjusted.get('rationale') or '')
if note and note not in rationale:
adjusted['rationale'] = f'{rationale} 賽後校準:{note}'
return adjusted
def _market_tier_from_item(item: dict[str, Any]) -> str:
market_type = str(item.get('market_type') or '')
@@ -530,6 +705,7 @@ def recalibrate_daily_card_confidence_payload(card_payload: dict[str, Any]) -> d
if '信心分數已套用新版資料品質校準' not in factors:
factors.append('信心分數已套用新版資料品質校準')
item['confidence_factors'] = factors[:8]
item = _apply_recent_market_calibration(item)
except Exception:
pass
next_group.append(item)
@@ -752,7 +928,8 @@ def _build_pick(
ev = _ev_percent(true_prob, odds)
edge = _edge_percent(true_prob, odds)
if true_prob < min_win_prob or ev < min_ev or edge < min_edge:
effective_min_win_prob, effective_min_ev = _calibrated_minimums(market_type, min_win_prob, min_ev)
if true_prob < effective_min_win_prob or ev < effective_min_ev or edge < min_edge:
return None
stake = _stake_units(true_prob, ev, risk_level, stake_cap)
@@ -811,7 +988,7 @@ def _build_pick(
market_overround=market_overround,
)
return {
return _apply_recent_market_calibration({
'match_id': match_id,
'match_label': match_label,
'market_type': market_type,
@@ -839,7 +1016,7 @@ def _build_pick(
f'{fair_note}{quality_note}{longshot_note}建議上限約新台幣 {_stake_amount_twd(stake):,} 元({stake:.2f}u且需等待最新傷停、先發與盤口刷新一致後才進入檢驗單。'
),
'data_checks': enriched_checks,
}
})
def _minimum_value_odds(true_prob: float, min_ev_percent: float) -> float:
@@ -870,19 +1047,20 @@ def _build_conditional_pick(
) -> dict[str, Any] | None:
"""尚無即時盤口時,產生可執行的預掛進場條件,而不是假裝已有市場賠率。"""
if true_prob < min_win_prob:
effective_min_win_prob, effective_min_ev = _calibrated_minimums(market_type, min_win_prob, min_ev)
if true_prob < effective_min_win_prob:
return None
target_odds = _minimum_value_odds(true_prob, min_ev)
target_odds = _minimum_value_odds(true_prob, effective_min_ev)
if target_odds <= 1 or target_odds > max_target_odds:
return None
implied = (1.0 / target_odds) * 100
edge = (true_prob * 100) - implied
stake = _stake_units(true_prob, min_ev, risk_level, stake_cap)
stake = _stake_units(true_prob, effective_min_ev, risk_level, stake_cap)
confidence = _confidence_score(
true_prob,
min_ev,
effective_min_ev,
edge,
market_tier,
decimal_odds=target_odds,
@@ -907,28 +1085,28 @@ def _build_conditional_pick(
odds_source_kind=odds_source_kind,
target_odds=target_odds,
true_prob=true_prob,
ev_percent=min_ev,
ev_percent=effective_min_ev,
edge_percent=edge,
)
quality_note = _quality_note(data_quality, has_market_odds)
confidence_band = _confidence_band(confidence, has_market_odds, risk_level)
confidence_factors = _confidence_factors(
true_prob=true_prob,
ev_percent=min_ev,
ev_percent=effective_min_ev,
edge_percent=edge,
data_quality=data_quality,
has_market_odds=has_market_odds,
market_tier=market_tier,
)
return {
return _apply_recent_market_calibration({
'match_id': match_id,
'match_label': match_label,
'market_type': market_type,
'selection': f'{selection}|預掛條件',
'target_odds': round(target_odds, 2),
'win_prob': round(true_prob * 100, 2),
'ev_percent': round(min_ev, 2),
'ev_percent': round(effective_min_ev, 2),
'stake_units': stake,
'stake_amount_twd': _stake_amount_twd(stake),
'unit_size_twd': DEFAULT_UNIT_STAKE_TWD,
@@ -945,11 +1123,11 @@ def _build_conditional_pick(
'edge_percent': round(edge, 2),
'rationale': (
f'此場尚未取得可用即時盤口,因此先給「預掛進場條件」。模型勝率 {true_prob * 100:.2f}%'
f'只有當平台賠率達到 {target_odds:.2f} 以上,才符合至少 {min_ev:.2f}% 的正期望值門檻;'
f'只有當平台賠率達到 {target_odds:.2f} 以上,才符合至少 {effective_min_ev:.2f}% 的正期望值門檻;'
f'若實際賠率低於此數字,直接跳過,不追價。建議監控上限約新台幣 {_stake_amount_twd(stake):,} 元({stake:.2f}u{quality_note}'
),
'data_checks': data_checks + [odds_source_label, '尚待盤口確認', '預掛最低賠率', '不得低於目標賠率下注'] + _quality_checks(data_quality, has_market_odds),
}
})
def _sort_picks(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -1016,6 +1194,7 @@ def _build_cross_match_parlays(safe_singles: list[dict[str, Any]]) -> list[dict[
combined_prob *= discount
min_prob = 0.22 if size == 2 else 0.12
min_ev = 2.5 if size == 2 else 5.0
min_prob, min_ev = _calibrated_minimums('跨場串關', min_prob, min_ev)
required_combo_odds = _minimum_value_odds(combined_prob, min_ev)
target_combo_odds = max(combined_odds, required_combo_odds)
max_combo_odds = 8.5 if size == 2 else 18.0
@@ -1066,7 +1245,7 @@ def _build_cross_match_parlays(safe_singles: list[dict[str, Any]]) -> list[dict[
edge_percent=edge,
)
parlays.append(
{
_apply_recent_market_calibration({
'match_id': f'PARLAY-CROSS-MATCH-{size}-{len(parlays) + 1}',
'match_label': ' + '.join(item['match_label'] for item in legs),
'market_type': '跨場串關',
@@ -1106,7 +1285,7 @@ def _build_cross_match_parlays(safe_singles: list[dict[str, Any]]) -> list[dict[
f'建議上限約新台幣 {_stake_amount_twd(stake_units):,} 元({stake_units:.2f}u。串關只作輔助倉位不能取代單關主策略。'
),
'data_checks': ['單關門檻通過', combo_source_label, '跨場去相關', f'相關性 {discount:.2f} 折減', '串關總賠率門檻', '串關 EV 重新計算'] + _quality_checks(combo_data_quality, combo_has_market_odds),
}
})
)
return _sort_picks(parlays)[:6]
@@ -1145,11 +1324,12 @@ def _build_same_game_parlays(picks_by_match: dict[str, list[dict[str, Any]]]) ->
combo_odds = first['target_odds'] * second['target_odds']
combo_prob = (first['win_prob'] / 100) * (second['win_prob'] / 100)
combo_prob *= 0.68
required_combo_odds = _minimum_value_odds(combo_prob, 8.0)
min_prob, min_ev = _calibrated_minimums('同場串關', 0.16, 8.0)
required_combo_odds = _minimum_value_odds(combo_prob, min_ev)
target_combo_odds = max(combo_odds, required_combo_odds)
ev = _ev_percent(combo_prob, target_combo_odds)
edge = _edge_percent(combo_prob, target_combo_odds)
if combo_prob < 0.16 or target_combo_odds > 12.0 or ev < 8.0 or edge <= 0:
if combo_prob < min_prob or target_combo_odds > 12.0 or ev < min_ev or edge <= 0:
continue
sgp_has_market_odds = False
@@ -1192,7 +1372,7 @@ def _build_same_game_parlays(picks_by_match: dict[str, list[dict[str, Any]]]) ->
)
sgps.append(
{
_apply_recent_market_calibration({
'match_id': match_id,
'match_label': f"{first['match_label']}【同場】",
'market_type': '同場串關',
@@ -1232,7 +1412,7 @@ def _build_same_game_parlays(picks_by_match: dict[str, list[dict[str, Any]]]) ->
f'建議上限約新台幣 {_stake_amount_twd(sgp_stake_units):,} 元({sgp_stake_units:.2f}u。此類組合波動極大只允許娛樂型小注不可當核心下注。'
),
'data_checks': ['同場不同市場', sgp_source_label or '模型推算最低門檻', '相關性 0.68 折減', '預掛總賠率門檻', '高 EV 門檻', '小倉位上限'] + _quality_checks(sgp_data_quality, sgp_has_market_odds),
}
})
)
return _sort_picks(sgps)[:4]
@@ -1598,6 +1778,13 @@ def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict
)
all_items = [*safe_singles, *high_risk_singles, *safe_parlays, *sgp_lotteries]
calibrated_count = sum(
1
for item in all_items
if any(str(check).startswith('近7天玩法校準') for check in (item.get('data_checks') or []))
)
if calibrated_count:
summary = f'{summary} 已套用近 7 天賽後玩法校準 {calibrated_count} 組,近期低命中玩法會自動提高門檻並縮小注碼。'
live_market_count = sum(1 for item in all_items if bool(item.get('has_market_odds')))
pre_market_count = len(all_items) - live_market_count
quality_counts: dict[str, int] = {}
@@ -1616,6 +1803,7 @@ def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict
**quality_counts,
'live_market_count': live_market_count,
'pre_market_count': pre_market_count,
'recent_market_calibrated_count': calibrated_count,
},
'execution_policy': (
'已有實盤盤口的候選可進入下注前檢查;尚未取得實盤者只能加入賠率監控,'