aria-code 4.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
football_data_client.py
ADDED
|
@@ -0,0 +1,1670 @@
|
|
|
1
|
+
"""
|
|
2
|
+
football_data_client.py — 足球数据客户端
|
|
3
|
+
==========================================
|
|
4
|
+
数据源:
|
|
5
|
+
- football-data.org (免费 API key: FOOTBALL_DATA_API_KEY)
|
|
6
|
+
- understat (无需 key, xG 数据, pip install understat)
|
|
7
|
+
- ESPN/Sofascore (备用爬虫)
|
|
8
|
+
|
|
9
|
+
支持联赛: EPL / Bundesliga / La Liga / Serie A / Ligue 1 / Champions League
|
|
10
|
+
|
|
11
|
+
配置:
|
|
12
|
+
~/.aria/.env 或环境变量:
|
|
13
|
+
FOOTBALL_DATA_API_KEY=your_free_key # 从 football-data.org 免费注册获取
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import requests
|
|
27
|
+
except Exception:
|
|
28
|
+
requests = None # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_API_BASE = "https://api.football-data.org/v4"
|
|
33
|
+
_REQ_CACHE: Dict[str, Tuple[float, Any]] = {}
|
|
34
|
+
_CACHE_TTL = 300 # 5 min
|
|
35
|
+
|
|
36
|
+
# ── League aliases ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
LEAGUE_IDS: Dict[str, str] = {
|
|
39
|
+
"pl": "PL", "epl": "PL", "premierleague": "PL", "英超": "PL",
|
|
40
|
+
"bl": "BL1", "bl1": "BL1", "bundesliga": "BL1", "德甲": "BL1",
|
|
41
|
+
"pd": "PD", "laliga": "PD", "ll": "PD", "西甲": "PD",
|
|
42
|
+
"sa": "SA", "seriea": "SA", "意甲": "SA",
|
|
43
|
+
"fl1": "FL1", "ligue1": "FL1", "l1": "FL1", "法甲": "FL1",
|
|
44
|
+
"cl": "CL", "ucl": "CL", "champions": "CL", "欧冠": "CL",
|
|
45
|
+
"el": "EL", "europaleague": "EL", "欧联": "EL",
|
|
46
|
+
"dl": "DED", "eredivisie": "DED", "荷甲": "DED",
|
|
47
|
+
"ppl": "PPL", "primeiraliga": "PPL", "葡超": "PPL",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
LEAGUE_NAMES: Dict[str, str] = {
|
|
51
|
+
"PL": "英超 Premier League 🏴",
|
|
52
|
+
"BL1": "德甲 Bundesliga 🇩🇪",
|
|
53
|
+
"PD": "西甲 La Liga 🇪🇸",
|
|
54
|
+
"SA": "意甲 Serie A 🇮🇹",
|
|
55
|
+
"FL1": "法甲 Ligue 1 🇫🇷",
|
|
56
|
+
"CL": "欧冠 Champions League 🏆",
|
|
57
|
+
"EL": "欧联杯 Europa League",
|
|
58
|
+
"DED": "荷甲 Eredivisie 🇳🇱",
|
|
59
|
+
"PPL": "葡超 Primeira Liga 🇵🇹",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_league(raw: str) -> str:
|
|
64
|
+
"""Normalize league alias to football-data.org competition code."""
|
|
65
|
+
key = raw.lower().replace(" ", "").replace("-", "")
|
|
66
|
+
return LEAGUE_IDS.get(key, raw.upper())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── HTTP helpers ───────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
_WARNED_NO_KEY = False # only warn once per process
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_football_key() -> str:
|
|
75
|
+
"""Load football-data.org API key from env, .env files, or providers.json."""
|
|
76
|
+
key = os.environ.get("FOOTBALL_DATA_API_KEY", "")
|
|
77
|
+
if not key:
|
|
78
|
+
import pathlib as _pl
|
|
79
|
+
# Check ~/.aria/.env and ~/.arthera/.env
|
|
80
|
+
for env_file in [
|
|
81
|
+
_pl.Path.home() / ".aria" / ".env",
|
|
82
|
+
_pl.Path.home() / ".arthera" / ".env",
|
|
83
|
+
]:
|
|
84
|
+
if env_file.exists():
|
|
85
|
+
try:
|
|
86
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
87
|
+
if line.startswith("FOOTBALL_DATA_API_KEY="):
|
|
88
|
+
key = line.split("=", 1)[1].strip()
|
|
89
|
+
break
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
if key:
|
|
93
|
+
break
|
|
94
|
+
if not key:
|
|
95
|
+
try:
|
|
96
|
+
import pathlib as _pl
|
|
97
|
+
p = _pl.Path.home() / ".arthera" / "providers.json"
|
|
98
|
+
if p.exists():
|
|
99
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
100
|
+
data = raw.get("data", {})
|
|
101
|
+
key = (
|
|
102
|
+
data.get("football_data", {}).get("api_key", "")
|
|
103
|
+
or data.get("footballdata", {}).get("api_key", "")
|
|
104
|
+
or data.get("football_data_org", {}).get("api_key", "")
|
|
105
|
+
)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
return key
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get(path: str, params: Optional[Dict] = None) -> Optional[Dict]:
|
|
112
|
+
"""GET from football-data.org API with simple cache."""
|
|
113
|
+
global _WARNED_NO_KEY
|
|
114
|
+
if requests is None:
|
|
115
|
+
if not _WARNED_NO_KEY:
|
|
116
|
+
_WARNED_NO_KEY = True
|
|
117
|
+
logger.info("football-data.org: requests 未安装,实时赛程降级为空;本地预测仍可用。")
|
|
118
|
+
return None
|
|
119
|
+
api_key = _load_football_key()
|
|
120
|
+
cache_key = path + json.dumps(params or {}, sort_keys=True)
|
|
121
|
+
now = time.time()
|
|
122
|
+
if cache_key in _REQ_CACHE:
|
|
123
|
+
ts, data = _REQ_CACHE[cache_key]
|
|
124
|
+
if now - ts < _CACHE_TTL:
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
headers = {"X-Auth-Token": api_key} if api_key else {}
|
|
128
|
+
try:
|
|
129
|
+
resp = requests.get(
|
|
130
|
+
f"{_API_BASE}{path}",
|
|
131
|
+
headers=headers,
|
|
132
|
+
params=params,
|
|
133
|
+
timeout=10,
|
|
134
|
+
)
|
|
135
|
+
if resp.status_code == 403:
|
|
136
|
+
if not _WARNED_NO_KEY:
|
|
137
|
+
_WARNED_NO_KEY = True
|
|
138
|
+
logger.info("football-data.org: 未配置 API key,使用 FIFA 排名估算(预测仍有效)。"
|
|
139
|
+
" 免费注册: https://www.football-data.org/client/register")
|
|
140
|
+
return None
|
|
141
|
+
if resp.status_code == 429:
|
|
142
|
+
logger.warning("football-data.org: 请求过于频繁 (免费版 10次/分钟)")
|
|
143
|
+
return None
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
data = resp.json()
|
|
146
|
+
_REQ_CACHE[cache_key] = (now, data)
|
|
147
|
+
return data
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.warning("football-data.org request failed: %s", exc)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# football-data.org national team IDs (2026 WC participants)
|
|
154
|
+
_WC_TEAM_IDS: Dict[str, int] = {
|
|
155
|
+
"algeria": 778, "argentina": 762, "australia": 779, "austria": 816,
|
|
156
|
+
"belgium": 805, "bosnia-herzegovina": 1060, "brazil": 764, "canada": 828,
|
|
157
|
+
"cape verde islands": 1930, "colombia": 818, "congo dr": 1934, "croatia": 799,
|
|
158
|
+
"curacao": 9460, "czechia": 798, "ecuador": 791, "egypt": 825,
|
|
159
|
+
"england": 770, "france": 773, "germany": 759, "ghana": 763,
|
|
160
|
+
"haiti": 836, "iran": 840, "iraq": 8062, "ivory coast": 1935,
|
|
161
|
+
"japan": 766, "jordan": 8049, "mexico": 769, "morocco": 815,
|
|
162
|
+
"netherlands": 8601, "new zealand": 783, "norway": 8872, "panama": 1836,
|
|
163
|
+
"paraguay": 761, "portugal": 765, "qatar": 8030, "saudi arabia": 801,
|
|
164
|
+
"scotland": 8873, "senegal": 804, "south africa": 774, "south korea": 772,
|
|
165
|
+
"spain": 760, "sweden": 792, "switzerland": 788, "tunisia": 802,
|
|
166
|
+
"turkey": 803, "united states": 771, "uruguay": 758, "uzbekistan": 8070,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _resolve_team_id(team_name: str) -> Optional[int]:
|
|
171
|
+
"""Resolve a team name to its football-data.org team ID."""
|
|
172
|
+
low = team_name.lower().strip()
|
|
173
|
+
# Normalize accented chars for lookup
|
|
174
|
+
import unicodedata
|
|
175
|
+
low = unicodedata.normalize("NFKD", low).encode("ascii", "ignore").decode()
|
|
176
|
+
if low in _WC_TEAM_IDS:
|
|
177
|
+
return _WC_TEAM_IDS[low]
|
|
178
|
+
# Partial match
|
|
179
|
+
for key, tid in _WC_TEAM_IDS.items():
|
|
180
|
+
if low in key or key in low:
|
|
181
|
+
return tid
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _wc_matches_for_team(team_name: str, limit: int = 6) -> List[Dict]:
|
|
186
|
+
"""Recent finished WC matches for a team, scanned from the competition feed.
|
|
187
|
+
|
|
188
|
+
National-team IDs via /teams/{id} are unreliable, but the WC competition
|
|
189
|
+
feed has every finished match — so derive form/H2H from there. This is why
|
|
190
|
+
'埃及 近期状态: 暂无数据' happened despite the Egypt match being in the API.
|
|
191
|
+
"""
|
|
192
|
+
data = _get("/competitions/WC/matches", {"status": "FINISHED"})
|
|
193
|
+
if not data:
|
|
194
|
+
return []
|
|
195
|
+
tl = (team_name or "").lower().strip()
|
|
196
|
+
out = []
|
|
197
|
+
for m in data.get("matches", []):
|
|
198
|
+
h = ((m.get("homeTeam") or {}).get("name") or "").lower()
|
|
199
|
+
a = ((m.get("awayTeam") or {}).get("name") or "").lower()
|
|
200
|
+
if tl and (tl in h or tl in a or h in tl or a in tl):
|
|
201
|
+
out.append(m)
|
|
202
|
+
return out[-limit:]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fetch_team_form(team_name: str, limit: int = 6) -> List[Dict]:
|
|
206
|
+
"""Fetch recent finished matches for a team (requires API key)."""
|
|
207
|
+
if not _load_football_key():
|
|
208
|
+
return []
|
|
209
|
+
try:
|
|
210
|
+
team_id = _resolve_team_id(team_name)
|
|
211
|
+
matches: List[Dict] = []
|
|
212
|
+
if team_id:
|
|
213
|
+
matches_data = _get(f"/teams/{team_id}/matches", {
|
|
214
|
+
"status": "FINISHED",
|
|
215
|
+
"limit": str(limit),
|
|
216
|
+
})
|
|
217
|
+
if matches_data:
|
|
218
|
+
matches = matches_data.get("matches", [])
|
|
219
|
+
# Fallback to the WC competition feed (national-team IDs unreliable)
|
|
220
|
+
if not matches:
|
|
221
|
+
matches = _wc_matches_for_team(team_name, limit)
|
|
222
|
+
return matches
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
logger.debug("_fetch_team_form(%s) failed: %s", team_name, exc)
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _fetch_h2h(team1: str, team2: str, limit: int = 10) -> List[Dict]:
|
|
229
|
+
"""Fetch H2H finished matches between two teams (requires API key)."""
|
|
230
|
+
if not _load_football_key():
|
|
231
|
+
return []
|
|
232
|
+
try:
|
|
233
|
+
team_id = _resolve_team_id(team1)
|
|
234
|
+
matches: List[Dict] = []
|
|
235
|
+
if team_id:
|
|
236
|
+
h2h_data = _get(f"/teams/{team_id}/matches", {
|
|
237
|
+
"competitions": "WC,CL,PL,BL1,SA,FL1,PD,EC",
|
|
238
|
+
"status": "FINISHED",
|
|
239
|
+
"limit": "50",
|
|
240
|
+
})
|
|
241
|
+
if h2h_data:
|
|
242
|
+
matches = h2h_data.get("matches", [])
|
|
243
|
+
# Fallback: scan WC competition feed for team1's matches
|
|
244
|
+
if not matches:
|
|
245
|
+
matches = _wc_matches_for_team(team1, limit=50)
|
|
246
|
+
t2_low = team2.lower()
|
|
247
|
+
filtered = [
|
|
248
|
+
m for m in matches
|
|
249
|
+
if t2_low in ((m.get("homeTeam") or {}).get("name") or "").lower()
|
|
250
|
+
or t2_low in ((m.get("awayTeam") or {}).get("name") or "").lower()
|
|
251
|
+
]
|
|
252
|
+
return filtered[:limit]
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
logger.debug("_fetch_h2h(%s, %s) failed: %s", team1, team2, exc)
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def get_standings(league: str) -> Optional[Dict]:
|
|
261
|
+
"""
|
|
262
|
+
Return league standings table.
|
|
263
|
+
league: "pl" / "bl" / "ll" / "sa" / "fl1" / "cl" / ...
|
|
264
|
+
"""
|
|
265
|
+
comp = _resolve_league(league)
|
|
266
|
+
data = _get(f"/competitions/{comp}/standings")
|
|
267
|
+
if not data:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
standings = data.get("standings", [])
|
|
271
|
+
total_table = next((s for s in standings if s.get("type") == "TOTAL"), None)
|
|
272
|
+
if not total_table:
|
|
273
|
+
total_table = standings[0] if standings else None
|
|
274
|
+
if not total_table:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
rows = []
|
|
278
|
+
for entry in total_table.get("table", []):
|
|
279
|
+
rows.append({
|
|
280
|
+
"pos": entry.get("position"),
|
|
281
|
+
"team": entry.get("team", {}).get("name", ""),
|
|
282
|
+
"played": entry.get("playedGames"),
|
|
283
|
+
"w": entry.get("won"),
|
|
284
|
+
"d": entry.get("draw"),
|
|
285
|
+
"l": entry.get("lost"),
|
|
286
|
+
"gf": entry.get("goalsFor"),
|
|
287
|
+
"ga": entry.get("goalsAgainst"),
|
|
288
|
+
"gd": entry.get("goalDifference"),
|
|
289
|
+
"pts": entry.get("points"),
|
|
290
|
+
"form": entry.get("form", ""),
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
comp_name = data.get("competition", {}).get("name", LEAGUE_NAMES.get(comp, comp))
|
|
294
|
+
season = data.get("season", {})
|
|
295
|
+
return {
|
|
296
|
+
"league": comp,
|
|
297
|
+
"league_name": comp_name,
|
|
298
|
+
"season_start": season.get("startDate", ""),
|
|
299
|
+
"season_end": season.get("endDate", ""),
|
|
300
|
+
"table": rows,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_fixtures(league: str, days_ahead: int = 7) -> Optional[List[Dict]]:
|
|
305
|
+
"""
|
|
306
|
+
Return upcoming fixtures within the next `days_ahead` days.
|
|
307
|
+
"""
|
|
308
|
+
comp = _resolve_league(league)
|
|
309
|
+
date_from = datetime.utcnow().strftime("%Y-%m-%d")
|
|
310
|
+
date_to = (datetime.utcnow() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
|
|
311
|
+
data = _get(f"/competitions/{comp}/matches", {
|
|
312
|
+
"status": "SCHEDULED",
|
|
313
|
+
"dateFrom": date_from,
|
|
314
|
+
"dateTo": date_to,
|
|
315
|
+
})
|
|
316
|
+
if not data:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
matches = []
|
|
320
|
+
for m in data.get("matches", []):
|
|
321
|
+
utc_str = m.get("utcDate", "")
|
|
322
|
+
try:
|
|
323
|
+
utc_dt = datetime.strptime(utc_str, "%Y-%m-%dT%H:%M:%SZ")
|
|
324
|
+
local_str = utc_dt.strftime("%m-%d %H:%M")
|
|
325
|
+
except Exception:
|
|
326
|
+
local_str = utc_str[:16]
|
|
327
|
+
matches.append({
|
|
328
|
+
"id": m.get("id"),
|
|
329
|
+
"date": local_str,
|
|
330
|
+
"home": m.get("homeTeam", {}).get("name", ""),
|
|
331
|
+
"away": m.get("awayTeam", {}).get("name", ""),
|
|
332
|
+
"matchday": m.get("matchday"),
|
|
333
|
+
"stage": m.get("stage", ""),
|
|
334
|
+
})
|
|
335
|
+
return matches
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_recent_results(league: str, team_name: str, n: int = 10) -> Optional[List[Dict]]:
|
|
339
|
+
"""
|
|
340
|
+
Return last N finished matches for a specific team in a league.
|
|
341
|
+
"""
|
|
342
|
+
comp = _resolve_league(league)
|
|
343
|
+
data = _get(f"/competitions/{comp}/matches", {"status": "FINISHED"})
|
|
344
|
+
if not data:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
team_lower = team_name.lower()
|
|
348
|
+
results = []
|
|
349
|
+
for m in reversed(data.get("matches", [])):
|
|
350
|
+
ht = m.get("homeTeam", {}).get("name", "")
|
|
351
|
+
at = m.get("awayTeam", {}).get("name", "")
|
|
352
|
+
if team_lower not in ht.lower() and team_lower not in at.lower():
|
|
353
|
+
continue
|
|
354
|
+
score = m.get("score", {}).get("fullTime", {})
|
|
355
|
+
hg = score.get("home")
|
|
356
|
+
ag = score.get("away")
|
|
357
|
+
if hg is None or ag is None:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
is_home = team_lower in ht.lower()
|
|
361
|
+
gf = hg if is_home else ag
|
|
362
|
+
ga = ag if is_home else hg
|
|
363
|
+
if gf > ga:
|
|
364
|
+
result = "W"
|
|
365
|
+
elif gf < ga:
|
|
366
|
+
result = "L"
|
|
367
|
+
else:
|
|
368
|
+
result = "D"
|
|
369
|
+
|
|
370
|
+
results.append({
|
|
371
|
+
"date": m.get("utcDate", "")[:10],
|
|
372
|
+
"home": ht,
|
|
373
|
+
"away": at,
|
|
374
|
+
"score": f"{hg}-{ag}",
|
|
375
|
+
"result": result,
|
|
376
|
+
"is_home": is_home,
|
|
377
|
+
"gf": gf,
|
|
378
|
+
"ga": ga,
|
|
379
|
+
})
|
|
380
|
+
if len(results) >= n:
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
return results
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def get_team_stats(league: str, team_name: str) -> Optional[Dict]:
|
|
387
|
+
"""Return aggregated stats for a team from recent matches."""
|
|
388
|
+
results = get_recent_results(league, team_name, n=10)
|
|
389
|
+
if not results:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
total = len(results)
|
|
393
|
+
wins = sum(1 for r in results if r["result"] == "W")
|
|
394
|
+
draws = sum(1 for r in results if r["result"] == "D")
|
|
395
|
+
losses = sum(1 for r in results if r["result"] == "L")
|
|
396
|
+
gf = sum(r["gf"] for r in results)
|
|
397
|
+
ga = sum(r["ga"] for r in results)
|
|
398
|
+
|
|
399
|
+
home_results = [r for r in results if r["is_home"]]
|
|
400
|
+
away_results = [r for r in results if not r["is_home"]]
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
"team": team_name,
|
|
404
|
+
"league": league,
|
|
405
|
+
"last_n": total,
|
|
406
|
+
"w": wins, "d": draws, "l": losses,
|
|
407
|
+
"gf": gf, "ga": ga,
|
|
408
|
+
"avg_gf": round(gf / total, 2) if total else 0,
|
|
409
|
+
"avg_ga": round(ga / total, 2) if total else 0,
|
|
410
|
+
"home_avg_gf": round(sum(r["gf"] for r in home_results) / len(home_results), 2) if home_results else 0,
|
|
411
|
+
"away_avg_gf": round(sum(r["gf"] for r in away_results) / len(away_results), 2) if away_results else 0,
|
|
412
|
+
"form": "".join(r["result"] for r in results[:5]),
|
|
413
|
+
"recent": results[:5],
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ── Poisson Match Predictor ───────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
def _poisson_pmf(k: int, lam: float) -> float:
|
|
420
|
+
"""P(X=k) where X ~ Poisson(lam)"""
|
|
421
|
+
import math
|
|
422
|
+
if lam <= 0:
|
|
423
|
+
return 1.0 if k == 0 else 0.0
|
|
424
|
+
return (lam ** k) * math.exp(-lam) / math.factorial(k)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def predict_match(
|
|
428
|
+
home_team: str,
|
|
429
|
+
away_team: str,
|
|
430
|
+
league: str,
|
|
431
|
+
home_attack: Optional[float] = None,
|
|
432
|
+
away_attack: Optional[float] = None,
|
|
433
|
+
home_defense: Optional[float] = None,
|
|
434
|
+
away_defense: Optional[float] = None,
|
|
435
|
+
home_adv: float = 1.25,
|
|
436
|
+
) -> Dict:
|
|
437
|
+
"""
|
|
438
|
+
Poisson-model match prediction.
|
|
439
|
+
|
|
440
|
+
If attack/defense params are None, fetches recent form data to estimate them.
|
|
441
|
+
Returns win/draw/loss probabilities + most likely scorelines.
|
|
442
|
+
"""
|
|
443
|
+
# -- fetch stats if not provided
|
|
444
|
+
if home_attack is None or away_attack is None:
|
|
445
|
+
comp = _resolve_league(league)
|
|
446
|
+
h_data = _get(f"/competitions/{comp}/matches", {"status": "FINISHED"})
|
|
447
|
+
league_avg_gf = 1.5 # global fallback
|
|
448
|
+
|
|
449
|
+
if h_data:
|
|
450
|
+
all_matches = h_data.get("matches", [])
|
|
451
|
+
if all_matches:
|
|
452
|
+
total_goals = sum(
|
|
453
|
+
(m.get("score", {}).get("fullTime", {}).get("home") or 0) +
|
|
454
|
+
(m.get("score", {}).get("fullTime", {}).get("away") or 0)
|
|
455
|
+
for m in all_matches
|
|
456
|
+
if m.get("score", {}).get("fullTime", {}).get("home") is not None
|
|
457
|
+
)
|
|
458
|
+
finished = sum(
|
|
459
|
+
1 for m in all_matches
|
|
460
|
+
if m.get("score", {}).get("fullTime", {}).get("home") is not None
|
|
461
|
+
)
|
|
462
|
+
if finished:
|
|
463
|
+
league_avg_gf = total_goals / (finished * 2)
|
|
464
|
+
|
|
465
|
+
ht = get_team_stats(league, home_team)
|
|
466
|
+
at = get_team_stats(league, away_team)
|
|
467
|
+
|
|
468
|
+
home_attack = (ht["home_avg_gf"] if ht else league_avg_gf) / league_avg_gf
|
|
469
|
+
away_attack = (at["away_avg_gf"] if at else league_avg_gf) / league_avg_gf
|
|
470
|
+
home_defense = (ht["avg_ga"] if ht else league_avg_gf) / league_avg_gf
|
|
471
|
+
away_defense = (at["avg_ga"] if at else league_avg_gf) / league_avg_gf
|
|
472
|
+
else:
|
|
473
|
+
league_avg_gf = 1.5
|
|
474
|
+
|
|
475
|
+
# -- expected goals
|
|
476
|
+
lambda_home = home_attack * away_defense * home_adv * league_avg_gf
|
|
477
|
+
lambda_away = away_attack * home_defense * league_avg_gf
|
|
478
|
+
|
|
479
|
+
lambda_home = max(0.3, min(lambda_home, 6.0))
|
|
480
|
+
lambda_away = max(0.3, min(lambda_away, 6.0))
|
|
481
|
+
|
|
482
|
+
# -- scoreline matrix (0-7 goals each)
|
|
483
|
+
max_goals = 8
|
|
484
|
+
score_probs: Dict[Tuple[int, int], float] = {}
|
|
485
|
+
home_win = draw = away_win = 0.0
|
|
486
|
+
|
|
487
|
+
for hg in range(max_goals):
|
|
488
|
+
ph = _poisson_pmf(hg, lambda_home)
|
|
489
|
+
for ag in range(max_goals):
|
|
490
|
+
pa = _poisson_pmf(ag, lambda_away)
|
|
491
|
+
p = ph * pa
|
|
492
|
+
score_probs[(hg, ag)] = p
|
|
493
|
+
if hg > ag:
|
|
494
|
+
home_win += p
|
|
495
|
+
elif hg == ag:
|
|
496
|
+
draw += p
|
|
497
|
+
else:
|
|
498
|
+
away_win += p
|
|
499
|
+
|
|
500
|
+
# -- top 5 most likely scorelines
|
|
501
|
+
top_scores = sorted(score_probs.items(), key=lambda x: -x[1])[:5]
|
|
502
|
+
|
|
503
|
+
# -- most likely scoreline from the probability matrix, not rounded lambdas
|
|
504
|
+
ml_home, ml_away = top_scores[0][0] if top_scores else (round(lambda_home), round(lambda_away))
|
|
505
|
+
|
|
506
|
+
# -- 1X2 odds (implied, decimal)
|
|
507
|
+
def implied_odds(p: float) -> float:
|
|
508
|
+
return round(1 / p, 2) if p > 0.01 else 99.0
|
|
509
|
+
|
|
510
|
+
# -- btts (both teams to score)
|
|
511
|
+
btts = 1 - _poisson_pmf(0, lambda_home) - _poisson_pmf(0, lambda_away) + _poisson_pmf(0, lambda_home) * _poisson_pmf(0, lambda_away)
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
"home_team": home_team,
|
|
515
|
+
"away_team": away_team,
|
|
516
|
+
"league": league,
|
|
517
|
+
"lambda_home": round(lambda_home, 2),
|
|
518
|
+
"lambda_away": round(lambda_away, 2),
|
|
519
|
+
"home_win": round(home_win, 3),
|
|
520
|
+
"draw": round(draw, 3),
|
|
521
|
+
"away_win": round(away_win, 3),
|
|
522
|
+
"btts": round(btts, 3),
|
|
523
|
+
"most_likely_score": f"{ml_home}-{ml_away}",
|
|
524
|
+
"top_scorelines": [
|
|
525
|
+
{"score": f"{hg}-{ag}", "prob": round(p * 100, 1)}
|
|
526
|
+
for (hg, ag), p in top_scores
|
|
527
|
+
],
|
|
528
|
+
"implied_odds": {
|
|
529
|
+
"home": implied_odds(home_win),
|
|
530
|
+
"draw": implied_odds(draw),
|
|
531
|
+
"away": implied_odds(away_win),
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Head-to-head ──────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
def get_head_to_head(team1: str, team2: str, league: str, limit: int = 10) -> Optional[Dict]:
|
|
539
|
+
"""Return head-to-head record between two teams."""
|
|
540
|
+
comp = _resolve_league(league)
|
|
541
|
+
data = _get(f"/competitions/{comp}/matches", {"status": "FINISHED"})
|
|
542
|
+
if not data:
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
t1 = team1.lower()
|
|
546
|
+
t2 = team2.lower()
|
|
547
|
+
h2h = []
|
|
548
|
+
|
|
549
|
+
for m in reversed(data.get("matches", [])):
|
|
550
|
+
ht = m.get("homeTeam", {}).get("name", "")
|
|
551
|
+
at = m.get("awayTeam", {}).get("name", "")
|
|
552
|
+
if not (
|
|
553
|
+
(t1 in ht.lower() and t2 in at.lower()) or
|
|
554
|
+
(t2 in ht.lower() and t1 in at.lower())
|
|
555
|
+
):
|
|
556
|
+
continue
|
|
557
|
+
score = m.get("score", {}).get("fullTime", {})
|
|
558
|
+
hg = score.get("home")
|
|
559
|
+
ag = score.get("away")
|
|
560
|
+
if hg is None:
|
|
561
|
+
continue
|
|
562
|
+
h2h.append({
|
|
563
|
+
"date": m.get("utcDate", "")[:10],
|
|
564
|
+
"home": ht,
|
|
565
|
+
"away": at,
|
|
566
|
+
"score": f"{hg}-{ag}",
|
|
567
|
+
})
|
|
568
|
+
if len(h2h) >= limit:
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
if not h2h:
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
t1_wins = sum(
|
|
575
|
+
1 for m in h2h
|
|
576
|
+
if (t1 in m["home"].lower() and int(m["score"][0]) > int(m["score"][-1])) or
|
|
577
|
+
(t1 in m["away"].lower() and int(m["score"][0]) < int(m["score"][-1]))
|
|
578
|
+
)
|
|
579
|
+
draws = sum(1 for m in h2h if m["score"][0] == m["score"][-1])
|
|
580
|
+
t2_wins = len(h2h) - t1_wins - draws
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
"team1": team1,
|
|
584
|
+
"team2": team2,
|
|
585
|
+
"total": len(h2h),
|
|
586
|
+
"team1_wins": t1_wins,
|
|
587
|
+
"draws": draws,
|
|
588
|
+
"team2_wins": t2_wins,
|
|
589
|
+
"matches": h2h,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# ── Live scores & today's matches ────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
def get_live_scores() -> Optional[List[Dict]]:
|
|
596
|
+
"""Return currently live matches across all competitions."""
|
|
597
|
+
data = _get("/matches", {"status": "IN_PLAY,PAUSED"})
|
|
598
|
+
if not data:
|
|
599
|
+
return None
|
|
600
|
+
return _format_match_list(data.get("matches", []), include_score=True)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def get_todays_matches() -> Optional[List[Dict]]:
|
|
604
|
+
"""Return all matches scheduled or played today (UTC)."""
|
|
605
|
+
today = datetime.utcnow().strftime("%Y-%m-%d")
|
|
606
|
+
data = _get("/matches", {"dateFrom": today, "dateTo": today})
|
|
607
|
+
if not data:
|
|
608
|
+
return None
|
|
609
|
+
return _format_match_list(data.get("matches", []), include_score=True)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def get_matches_by_date(date_str: str) -> Optional[List[Dict]]:
|
|
613
|
+
"""Return matches for a specific date (YYYY-MM-DD)."""
|
|
614
|
+
data = _get("/matches", {"dateFrom": date_str, "dateTo": date_str})
|
|
615
|
+
if not data:
|
|
616
|
+
return None
|
|
617
|
+
return _format_match_list(data.get("matches", []), include_score=True)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _format_match_list(matches: List[Dict], include_score: bool = False) -> List[Dict]:
|
|
621
|
+
"""Normalize a list of raw API match objects."""
|
|
622
|
+
result = []
|
|
623
|
+
for m in matches:
|
|
624
|
+
score = m.get("score", {})
|
|
625
|
+
ft = score.get("fullTime", {})
|
|
626
|
+
ht_s = score.get("halfTime", {})
|
|
627
|
+
status = m.get("status", "")
|
|
628
|
+
utc_str = m.get("utcDate", "")
|
|
629
|
+
try:
|
|
630
|
+
utc_dt = datetime.strptime(utc_str, "%Y-%m-%dT%H:%M:%SZ")
|
|
631
|
+
time_str = utc_dt.strftime("%m-%d %H:%M")
|
|
632
|
+
except Exception:
|
|
633
|
+
time_str = utc_str[:16]
|
|
634
|
+
|
|
635
|
+
entry = {
|
|
636
|
+
"id": m.get("id"),
|
|
637
|
+
"competition": m.get("competition", {}).get("name", ""),
|
|
638
|
+
"comp_code": m.get("competition", {}).get("code", ""),
|
|
639
|
+
"date": time_str,
|
|
640
|
+
"status": status,
|
|
641
|
+
"home": m.get("homeTeam", {}).get("name", ""),
|
|
642
|
+
"away": m.get("awayTeam", {}).get("name", ""),
|
|
643
|
+
"matchday": m.get("matchday"),
|
|
644
|
+
"stage": m.get("stage", ""),
|
|
645
|
+
}
|
|
646
|
+
if include_score:
|
|
647
|
+
hg = ft.get("home")
|
|
648
|
+
ag = ft.get("away")
|
|
649
|
+
if hg is not None and ag is not None:
|
|
650
|
+
entry["score"] = f"{hg}-{ag}"
|
|
651
|
+
entry["ht_score"] = f"{ht_s.get('home','-')}-{ht_s.get('away','-')}"
|
|
652
|
+
else:
|
|
653
|
+
entry["score"] = None
|
|
654
|
+
entry["ht_score"] = None
|
|
655
|
+
result.append(entry)
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ── World Cup / tournament helpers ────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
# football-data.org competition codes for major tournaments
|
|
662
|
+
TOURNAMENT_CODES = {
|
|
663
|
+
"wc": "WC", "worldcup": "WC", "世界杯": "WC", "fifa": "WC",
|
|
664
|
+
"ec": "EC", "euro": "EC", "欧洲杯": "EC",
|
|
665
|
+
"ca": "CA", "copaamerica": "CA", "美洲杯": "CA",
|
|
666
|
+
"afc": "AFC", "亚洲杯": "AFC",
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def get_tournament_matches(tournament: str = "WC", stage: Optional[str] = None) -> Optional[List[Dict]]:
|
|
671
|
+
"""
|
|
672
|
+
Return all matches for a major tournament (World Cup, Euros, etc.).
|
|
673
|
+
stage: 'GROUP_STAGE' / 'ROUND_OF_16' / 'QUARTER_FINAL' / 'SEMI_FINAL' / 'FINAL'
|
|
674
|
+
"""
|
|
675
|
+
code = TOURNAMENT_CODES.get(tournament.lower().replace(" ", ""), tournament.upper())
|
|
676
|
+
params: Dict[str, str] = {}
|
|
677
|
+
if stage:
|
|
678
|
+
params["stage"] = stage
|
|
679
|
+
data = _get(f"/competitions/{code}/matches", params)
|
|
680
|
+
if not data:
|
|
681
|
+
return None
|
|
682
|
+
return _format_match_list(data.get("matches", []), include_score=True)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def get_tournament_standings(tournament: str = "WC") -> Optional[Dict]:
|
|
686
|
+
"""Return group standings for a tournament."""
|
|
687
|
+
code = TOURNAMENT_CODES.get(tournament.lower().replace(" ", ""), tournament.upper())
|
|
688
|
+
data = _get(f"/competitions/{code}/standings")
|
|
689
|
+
if not data:
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
groups = {}
|
|
693
|
+
for group in data.get("standings", []):
|
|
694
|
+
gtype = group.get("type", "")
|
|
695
|
+
if gtype not in ("HOME", "AWAY"): # skip home/away splits
|
|
696
|
+
g_name = group.get("group", gtype)
|
|
697
|
+
rows = []
|
|
698
|
+
for entry in group.get("table", []):
|
|
699
|
+
rows.append({
|
|
700
|
+
"pos": entry.get("position"),
|
|
701
|
+
"team": entry.get("team", {}).get("name", ""),
|
|
702
|
+
"played": entry.get("playedGames"),
|
|
703
|
+
"w": entry.get("won"),
|
|
704
|
+
"d": entry.get("draw"),
|
|
705
|
+
"l": entry.get("lost"),
|
|
706
|
+
"gf": entry.get("goalsFor"),
|
|
707
|
+
"ga": entry.get("goalsAgainst"),
|
|
708
|
+
"pts": entry.get("points"),
|
|
709
|
+
})
|
|
710
|
+
groups[g_name] = rows
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
"tournament": data.get("competition", {}).get("name", tournament.upper()),
|
|
714
|
+
"groups": groups,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def find_team_matches(tournament: str, team_name: str) -> Optional[List[Dict]]:
|
|
719
|
+
"""Find all matches for a specific team in a tournament."""
|
|
720
|
+
all_matches = get_tournament_matches(tournament)
|
|
721
|
+
if not all_matches:
|
|
722
|
+
return None
|
|
723
|
+
tlow = team_name.lower()
|
|
724
|
+
return [m for m in all_matches
|
|
725
|
+
if tlow in m.get("home", "").lower() or tlow in m.get("away", "").lower()]
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# ── FIFA ranking-based national team strength table (WC 2026) ─────────────────
|
|
729
|
+
# attack = avg goals scored per game (league avg = 1.0)
|
|
730
|
+
# defense = avg goals conceded per game (lower = better, league avg = 1.0)
|
|
731
|
+
# ranking = FIFA world ranking (approximate, early 2026)
|
|
732
|
+
_FIFA_RATINGS: Dict[str, Dict] = {
|
|
733
|
+
"argentina": {"attack": 1.90, "defense": 0.62, "ranking": 1, "name": "阿根廷"},
|
|
734
|
+
"france": {"attack": 1.85, "defense": 0.65, "ranking": 2, "name": "法国"},
|
|
735
|
+
"england": {"attack": 1.78, "defense": 0.68, "ranking": 3, "name": "英格兰"},
|
|
736
|
+
"brazil": {"attack": 1.82, "defense": 0.67, "ranking": 4, "name": "巴西"},
|
|
737
|
+
"portugal": {"attack": 1.80, "defense": 0.70, "ranking": 5, "name": "葡萄牙"},
|
|
738
|
+
"belgium": {"attack": 1.68, "defense": 0.72, "ranking": 6, "name": "比利时"},
|
|
739
|
+
"spain": {"attack": 1.72, "defense": 0.68, "ranking": 7, "name": "西班牙"},
|
|
740
|
+
"netherlands": {"attack": 1.65, "defense": 0.72, "ranking": 8, "name": "荷兰"},
|
|
741
|
+
"croatia": {"attack": 1.55, "defense": 0.74, "ranking": 9, "name": "克罗地亚"},
|
|
742
|
+
"italy": {"attack": 1.55, "defense": 0.72, "ranking": 10, "name": "意大利"},
|
|
743
|
+
"germany": {"attack": 1.68, "defense": 0.73, "ranking": 11, "name": "德国"},
|
|
744
|
+
"united states": {"attack": 1.52, "defense": 0.77, "ranking": 13, "name": "美国"},
|
|
745
|
+
"usa": {"attack": 1.52, "defense": 0.77, "ranking": 13, "name": "美国"},
|
|
746
|
+
"mexico": {"attack": 1.45, "defense": 0.80, "ranking": 14, "name": "墨西哥"},
|
|
747
|
+
"colombia": {"attack": 1.55, "defense": 0.78, "ranking": 15, "name": "哥伦比亚"},
|
|
748
|
+
"morocco": {"attack": 1.42, "defense": 0.76, "ranking": 16, "name": "摩洛哥"},
|
|
749
|
+
"senegal": {"attack": 1.38, "defense": 0.78, "ranking": 19, "name": "塞内加尔"},
|
|
750
|
+
"uruguay": {"attack": 1.50, "defense": 0.77, "ranking": 20, "name": "乌拉圭"},
|
|
751
|
+
"denmark": {"attack": 1.48, "defense": 0.74, "ranking": 22, "name": "丹麦"},
|
|
752
|
+
"switzerland": {"attack": 1.45, "defense": 0.74, "ranking": 23, "name": "瑞士"},
|
|
753
|
+
"serbia": {"attack": 1.45, "defense": 0.77, "ranking": 24, "name": "塞尔维亚"},
|
|
754
|
+
"austria": {"attack": 1.42, "defense": 0.78, "ranking": 25, "name": "奥地利"},
|
|
755
|
+
"ukraine": {"attack": 1.40, "defense": 0.78, "ranking": 27, "name": "乌克兰"},
|
|
756
|
+
"turkey": {"attack": 1.38, "defense": 0.80, "ranking": 28, "name": "土耳其"},
|
|
757
|
+
"czechia": {"attack": 1.38, "defense": 0.79, "ranking": 29, "name": "捷克"},
|
|
758
|
+
"czech republic": {"attack": 1.38, "defense": 0.79, "ranking": 29, "name": "捷克"},
|
|
759
|
+
"poland": {"attack": 1.35, "defense": 0.81, "ranking": 31, "name": "波兰"},
|
|
760
|
+
"chile": {"attack": 1.35, "defense": 0.82, "ranking": 32, "name": "智利"},
|
|
761
|
+
"japan": {"attack": 1.37, "defense": 0.80, "ranking": 33, "name": "日本"},
|
|
762
|
+
"south korea": {"attack": 1.33, "defense": 0.81, "ranking": 35, "name": "韩国"},
|
|
763
|
+
"australia": {"attack": 1.30, "defense": 0.83, "ranking": 37, "name": "澳大利亚"},
|
|
764
|
+
"hungary": {"attack": 1.30, "defense": 0.82, "ranking": 38, "name": "匈牙利"},
|
|
765
|
+
"canada": {"attack": 1.30, "defense": 0.84, "ranking": 40, "name": "加拿大"},
|
|
766
|
+
"nigeria": {"attack": 1.30, "defense": 0.84, "ranking": 41, "name": "尼日利亚"},
|
|
767
|
+
"peru": {"attack": 1.28, "defense": 0.83, "ranking": 43, "name": "秘鲁"},
|
|
768
|
+
"ivory coast": {"attack": 1.28, "defense": 0.84, "ranking": 44, "name": "科特迪瓦"},
|
|
769
|
+
"venezuela": {"attack": 1.25, "defense": 0.85, "ranking": 46, "name": "委内瑞拉"},
|
|
770
|
+
"iran": {"attack": 1.25, "defense": 0.86, "ranking": 47, "name": "伊朗"},
|
|
771
|
+
"ecuador": {"attack": 1.35, "defense": 0.82, "ranking": 47, "name": "厄瓜多尔"},
|
|
772
|
+
"saudi arabia": {"attack": 1.25, "defense": 0.85, "ranking": 48, "name": "沙特"},
|
|
773
|
+
"paraguay": {"attack": 1.22, "defense": 0.86, "ranking": 54, "name": "巴拉圭"},
|
|
774
|
+
"cameroon": {"attack": 1.22, "defense": 0.87, "ranking": 52, "name": "喀麦隆"},
|
|
775
|
+
"ghana": {"attack": 1.20, "defense": 0.87, "ranking": 55, "name": "加纳"},
|
|
776
|
+
"bosnia-herzegovina": {"attack": 1.25, "defense": 0.84, "ranking": 58, "name": "波黑"},
|
|
777
|
+
"bosnia and herzegovina": {"attack": 1.25, "defense": 0.84, "ranking": 58, "name": "波黑"},
|
|
778
|
+
"bosnia": {"attack": 1.25, "defense": 0.84, "ranking": 58, "name": "波黑"},
|
|
779
|
+
"algeria": {"attack": 1.22, "defense": 0.86, "ranking": 53, "name": "阿尔及利亚"},
|
|
780
|
+
"south africa": {"attack": 1.18, "defense": 0.88, "ranking": 60, "name": "南非"},
|
|
781
|
+
"romania": {"attack": 1.28, "defense": 0.83, "ranking": 44, "name": "罗马尼亚"},
|
|
782
|
+
"slovakia": {"attack": 1.25, "defense": 0.84, "ranking": 46, "name": "斯洛伐克"},
|
|
783
|
+
"scotland": {"attack": 1.28, "defense": 0.83, "ranking": 40, "name": "苏格兰"},
|
|
784
|
+
"wales": {"attack": 1.28, "defense": 0.84, "ranking": 41, "name": "威尔士"},
|
|
785
|
+
"tunisia": {"attack": 1.18, "defense": 0.88, "ranking": 62, "name": "突尼斯"},
|
|
786
|
+
"iraq": {"attack": 1.20, "defense": 0.87, "ranking": 68, "name": "伊拉克"},
|
|
787
|
+
"honduras": {"attack": 1.12, "defense": 0.89, "ranking": 74, "name": "洪都拉斯"},
|
|
788
|
+
"jamaica": {"attack": 1.12, "defense": 0.89, "ranking": 72, "name": "牙买加"},
|
|
789
|
+
"panama": {"attack": 1.12, "defense": 0.89, "ranking": 73, "name": "巴拿马"},
|
|
790
|
+
"costa rica": {"attack": 1.15, "defense": 0.88, "ranking": 69, "name": "哥斯达黎加"},
|
|
791
|
+
"bolivia": {"attack": 1.12, "defense": 0.90, "ranking": 77, "name": "玻利维亚"},
|
|
792
|
+
"new zealand": {"attack": 1.10, "defense": 0.91, "ranking": 80, "name": "新西兰"},
|
|
793
|
+
"qatar": {"attack": 1.10, "defense": 0.90, "ranking": 82, "name": "卡塔尔"},
|
|
794
|
+
"cuba": {"attack": 1.05, "defense": 0.93, "ranking": 95, "name": "古巴"},
|
|
795
|
+
"curacao": {"attack": 1.10, "defense": 0.90, "ranking": 70, "name": "库拉索"},
|
|
796
|
+
"curaçao": {"attack": 1.10, "defense": 0.90, "ranking": 70, "name": "库拉索"},
|
|
797
|
+
"trinidad": {"attack": 1.12, "defense": 0.90, "ranking": 75, "name": "特多"},
|
|
798
|
+
"trinidad and tobago": {"attack": 1.12, "defense": 0.90, "ranking": 75, "name": "特多"},
|
|
799
|
+
"haiti": {"attack": 1.08, "defense": 0.92, "ranking": 85, "name": "海地"},
|
|
800
|
+
"guatemala": {"attack": 1.10, "defense": 0.91, "ranking": 78, "name": "危地马拉"},
|
|
801
|
+
"el salvador": {"attack": 1.08, "defense": 0.92, "ranking": 82, "name": "萨尔瓦多"},
|
|
802
|
+
"egypt": {"attack": 1.30, "defense": 0.82, "ranking": 36, "name": "埃及"},
|
|
803
|
+
"china": {"attack": 1.08, "defense": 0.92, "ranking": 88, "name": "中国"},
|
|
804
|
+
"china pr": {"attack": 1.08, "defense": 0.92, "ranking": 88, "name": "中国"},
|
|
805
|
+
"north korea": {"attack": 1.05, "defense": 0.93, "ranking": 112, "name": "朝鲜"},
|
|
806
|
+
"vietnam": {"attack": 1.05, "defense": 0.93, "ranking": 116, "name": "越南"},
|
|
807
|
+
"cape verde": {"attack": 1.18, "defense": 0.87, "ranking": 62, "name": "佛得角"},
|
|
808
|
+
"dr congo": {"attack": 1.28, "defense": 0.83, "ranking": 28, "name": "刚果金"},
|
|
809
|
+
"democratic republic of congo": {"attack": 1.28, "defense": 0.83, "ranking": 28, "name": "刚果金"},
|
|
810
|
+
"congo dr": {"attack": 1.28, "defense": 0.83, "ranking": 28, "name": "刚果金"},
|
|
811
|
+
"congo": {"attack": 1.10, "defense": 0.91, "ranking": 84, "name": "刚果布"},
|
|
812
|
+
"republic of congo": {"attack": 1.10, "defense": 0.91, "ranking": 84, "name": "刚果布"},
|
|
813
|
+
"mali": {"attack": 1.22, "defense": 0.86, "ranking": 54, "name": "马里"},
|
|
814
|
+
"uzbekistan": {"attack": 1.25, "defense": 0.85, "ranking": 63, "name": "乌兹别克斯坦"},
|
|
815
|
+
"philippines": {"attack": 1.05, "defense": 0.93, "ranking": 134, "name": "菲律宾"},
|
|
816
|
+
"thailand": {"attack": 1.08, "defense": 0.92, "ranking": 111, "name": "泰国"},
|
|
817
|
+
"norway": {"attack": 1.42, "defense": 0.78, "ranking": 26, "name": "挪威"},
|
|
818
|
+
"sweden": {"attack": 1.38, "defense": 0.79, "ranking": 30, "name": "瑞典"},
|
|
819
|
+
"finland": {"attack": 1.28, "defense": 0.83, "ranking": 43, "name": "芬兰"},
|
|
820
|
+
"greece": {"attack": 1.30, "defense": 0.82, "ranking": 46, "name": "希腊"},
|
|
821
|
+
"russia": {"attack": 1.40, "defense": 0.79, "ranking": 26, "name": "俄罗斯"},
|
|
822
|
+
"kosovo": {"attack": 1.05, "defense": 1.02, "ranking": 120, "name": "科索沃"},
|
|
823
|
+
"northern ireland": {"attack": 1.22, "defense": 0.86, "ranking": 55, "name": "北爱尔兰"},
|
|
824
|
+
"albania": {"attack": 1.28, "defense": 0.83, "ranking": 66, "name": "阿尔巴尼亚"},
|
|
825
|
+
"north macedonia": {"attack": 1.18, "defense": 0.88, "ranking": 72, "name": "北马其顿"},
|
|
826
|
+
"iceland": {"attack": 1.28, "defense": 0.83, "ranking": 65, "name": "冰岛"},
|
|
827
|
+
"republic of ireland": {"attack": 1.22, "defense": 0.84, "ranking": 62, "name": "爱尔兰"},
|
|
828
|
+
"ireland": {"attack": 1.22, "defense": 0.84, "ranking": 62, "name": "爱尔兰"},
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
# WC 2026 host nations (slight home-field advantage)
|
|
832
|
+
_WC_HOST_NATIONS = {"united states", "usa", "canada", "mexico"}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _find_fifa_rating(team_name: str) -> Optional[Dict]:
|
|
836
|
+
"""Fuzzy-match team_name against _FIFA_RATINGS dict. Handles Chinese names."""
|
|
837
|
+
# Translate Chinese names via _CN_TEAM_MAP (defined later in module; resolved at call time)
|
|
838
|
+
try:
|
|
839
|
+
en = _CN_TEAM_MAP.get(team_name)
|
|
840
|
+
if en:
|
|
841
|
+
team_name = en
|
|
842
|
+
except NameError:
|
|
843
|
+
pass
|
|
844
|
+
low = team_name.lower().strip()
|
|
845
|
+
if low in _FIFA_RATINGS:
|
|
846
|
+
return {**_FIFA_RATINGS[low], "key": low}
|
|
847
|
+
for key, val in _FIFA_RATINGS.items():
|
|
848
|
+
if low in key or key in low:
|
|
849
|
+
return {**val, "key": key}
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _title_team_name(name: str) -> str:
|
|
854
|
+
small_words = {"and", "of", "the"}
|
|
855
|
+
return " ".join(
|
|
856
|
+
word if word in small_words else word.capitalize()
|
|
857
|
+
for word in str(name or "").replace("_", " ").split()
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def team_display_name(team_name: Any, locale: str = "zh") -> str:
|
|
862
|
+
"""Return a stable display name for a team in the requested output locale."""
|
|
863
|
+
raw = str(team_name or "").strip()
|
|
864
|
+
if not raw:
|
|
865
|
+
return "-"
|
|
866
|
+
low = raw.lower()
|
|
867
|
+
want_en = str(locale or "zh").lower().startswith("en")
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
cn_map = _CN_TEAM_MAP
|
|
871
|
+
except NameError:
|
|
872
|
+
cn_map = {}
|
|
873
|
+
|
|
874
|
+
if want_en:
|
|
875
|
+
if raw in cn_map:
|
|
876
|
+
return _title_team_name(cn_map[raw])
|
|
877
|
+
if low in _FIFA_RATINGS:
|
|
878
|
+
return _title_team_name(low)
|
|
879
|
+
for cn, en in cn_map.items():
|
|
880
|
+
if low == str(en).lower() or raw == cn:
|
|
881
|
+
return _title_team_name(en)
|
|
882
|
+
for key, val in _FIFA_RATINGS.items():
|
|
883
|
+
if low == key.lower() or raw == str(val.get("name", "")):
|
|
884
|
+
return _title_team_name(key)
|
|
885
|
+
return _title_team_name(raw)
|
|
886
|
+
|
|
887
|
+
if raw in cn_map:
|
|
888
|
+
return raw
|
|
889
|
+
for cn, en in cn_map.items():
|
|
890
|
+
if low == str(en).lower():
|
|
891
|
+
return cn
|
|
892
|
+
rating = _find_fifa_rating(raw)
|
|
893
|
+
if rating and rating.get("name"):
|
|
894
|
+
return str(rating["name"])
|
|
895
|
+
return raw
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def football_prediction_quality(pred: Dict[str, Any]) -> Dict[str, Any]:
|
|
899
|
+
"""Summarize visible data quality for football predictions."""
|
|
900
|
+
missing: list[str] = []
|
|
901
|
+
if pred.get("home_ranking") in (None, "", "?"):
|
|
902
|
+
missing.append("home_fifa_ranking")
|
|
903
|
+
if pred.get("away_ranking") in (None, "", "?"):
|
|
904
|
+
missing.append("away_fifa_ranking")
|
|
905
|
+
if pred.get("home_form") in (None, "", "?????") or pred.get("away_form") in (None, "", "?????"):
|
|
906
|
+
missing.append("recent_form")
|
|
907
|
+
if not (pred.get("h2h_advantage") or pred.get("total_matches")):
|
|
908
|
+
missing.append("h2h")
|
|
909
|
+
if not pred.get("calibrated_matches"):
|
|
910
|
+
missing.append("wc_calibration")
|
|
911
|
+
missing = list(dict.fromkeys(missing))
|
|
912
|
+
return {
|
|
913
|
+
"status": "estimated" if missing else "complete",
|
|
914
|
+
"missing": missing,
|
|
915
|
+
"basis": "FIFA/Elo strength + Poisson estimate" if missing else "calibrated football data + Poisson",
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def football_quality_missing_labels(missing: List[str], locale: str = "zh") -> List[str]:
|
|
920
|
+
if str(locale or "zh").lower().startswith("en"):
|
|
921
|
+
labels = {
|
|
922
|
+
"home_fifa_ranking": "home FIFA ranking",
|
|
923
|
+
"away_fifa_ranking": "away FIFA ranking",
|
|
924
|
+
"recent_form": "recent form",
|
|
925
|
+
"h2h": "head-to-head",
|
|
926
|
+
"wc_calibration": "WC calibration sample",
|
|
927
|
+
}
|
|
928
|
+
else:
|
|
929
|
+
labels = {
|
|
930
|
+
"home_fifa_ranking": "主队 FIFA 排名",
|
|
931
|
+
"away_fifa_ranking": "客队 FIFA 排名",
|
|
932
|
+
"recent_form": "近期战绩",
|
|
933
|
+
"h2h": "历史交锋",
|
|
934
|
+
"wc_calibration": "世界杯样本校准",
|
|
935
|
+
}
|
|
936
|
+
return [labels.get(item, item) for item in missing or []]
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def predict_wc_match(
|
|
940
|
+
home_team: str,
|
|
941
|
+
away_team: str,
|
|
942
|
+
neutral_venue: bool = True,
|
|
943
|
+
) -> Dict:
|
|
944
|
+
"""
|
|
945
|
+
WC 2026 match prediction.
|
|
946
|
+
优先使用 Elo + Dixon-Coles 引擎(packages/quant_engine/sports),
|
|
947
|
+
若模块不可用则回落到原 FIFA 静态表 + 纯泊松预测。
|
|
948
|
+
"""
|
|
949
|
+
import math
|
|
950
|
+
|
|
951
|
+
# Translate Chinese team names to English (resolved at call time after _CN_TEAM_MAP is defined)
|
|
952
|
+
try:
|
|
953
|
+
home_team = _CN_TEAM_MAP.get(home_team, home_team)
|
|
954
|
+
away_team = _CN_TEAM_MAP.get(away_team, away_team)
|
|
955
|
+
except NameError:
|
|
956
|
+
pass
|
|
957
|
+
|
|
958
|
+
# ── 优先使用新量化引擎 ─────────────────────────────────────────────────────
|
|
959
|
+
try:
|
|
960
|
+
from packages.quant_engine.sports.predictor import get_predictor
|
|
961
|
+
from packages.quant_engine.sports.tracker import (
|
|
962
|
+
sync_elo_from_wc, fetch_wc_league_avg,
|
|
963
|
+
record_prediction, fetch_wc_rho, auto_calibrate,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# 赛前自动同步:更新 Elo + 动态场均进球 + 校准 ρ + 自动优化参数
|
|
967
|
+
sync_result = sync_elo_from_wc(_get)
|
|
968
|
+
league_avg = fetch_wc_league_avg(_get)
|
|
969
|
+
fetch_wc_rho(_get)
|
|
970
|
+
if sync_result.get("synced", 0) > 0:
|
|
971
|
+
auto_calibrate(_get)
|
|
972
|
+
|
|
973
|
+
# 拉取真实 form/H2H 数据
|
|
974
|
+
form_home_raw = _fetch_team_form(home_team, limit=6)
|
|
975
|
+
form_away_raw = _fetch_team_form(away_team, limit=6)
|
|
976
|
+
h2h_matches = _fetch_h2h(home_team, away_team, limit=10)
|
|
977
|
+
|
|
978
|
+
predictor = get_predictor()
|
|
979
|
+
result = predictor.predict(
|
|
980
|
+
home_team, away_team,
|
|
981
|
+
league="wc",
|
|
982
|
+
neutral_venue=neutral_venue,
|
|
983
|
+
form_home=form_home_raw or None,
|
|
984
|
+
form_away=form_away_raw or None,
|
|
985
|
+
h2h_matches=h2h_matches or None,
|
|
986
|
+
league_avg_override=league_avg,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# 记录本次预测(含 λ + Elo,供 calibrator 使用)
|
|
990
|
+
try:
|
|
991
|
+
import time as _t
|
|
992
|
+
today = _t.strftime("%Y-%m-%d", _t.gmtime())
|
|
993
|
+
record_prediction(
|
|
994
|
+
home_team, away_team,
|
|
995
|
+
result["home_win"], result["draw"], result["away_win"],
|
|
996
|
+
match_date=today, competition="WC",
|
|
997
|
+
extra={
|
|
998
|
+
"lambda_home": result.get("lambda_home"),
|
|
999
|
+
"lambda_away": result.get("lambda_away"),
|
|
1000
|
+
"home_elo": result.get("home_elo"),
|
|
1001
|
+
"away_elo": result.get("away_elo"),
|
|
1002
|
+
"league_avg": result.get("league_avg_goals"),
|
|
1003
|
+
# raw (pre-temperature) probs — train the confidence
|
|
1004
|
+
# calibrator on these so it never double-shrinks
|
|
1005
|
+
"raw_home_win": result.get("raw_home_win"),
|
|
1006
|
+
"raw_draw": result.get("raw_draw"),
|
|
1007
|
+
"raw_away_win": result.get("raw_away_win"),
|
|
1008
|
+
},
|
|
1009
|
+
)
|
|
1010
|
+
except Exception:
|
|
1011
|
+
pass
|
|
1012
|
+
# 补充 format_prediction_block 需要的旧格式字段
|
|
1013
|
+
hr = _find_fifa_rating(home_team) or {}
|
|
1014
|
+
ar = _find_fifa_rating(away_team) or {}
|
|
1015
|
+
result.setdefault("home_name_cn", team_display_name(hr.get("name", home_team), "zh"))
|
|
1016
|
+
result.setdefault("away_name_cn", team_display_name(ar.get("name", away_team), "zh"))
|
|
1017
|
+
result.setdefault("home_name_en", team_display_name(home_team, "en"))
|
|
1018
|
+
result.setdefault("away_name_en", team_display_name(away_team, "en"))
|
|
1019
|
+
result.setdefault("home_ranking", hr.get("ranking", "?"))
|
|
1020
|
+
result.setdefault("away_ranking", ar.get("ranking", "?"))
|
|
1021
|
+
result.setdefault("calibrated_matches", 0)
|
|
1022
|
+
result.setdefault("home_adv", 1.0 if neutral_venue else 1.12)
|
|
1023
|
+
result["data_quality"] = football_prediction_quality(result)
|
|
1024
|
+
result["implied_odds"] = result.get("implied_odds", {
|
|
1025
|
+
"home": round(1/result["home_win"], 2) if result["home_win"] > 0.01 else 99,
|
|
1026
|
+
"draw": round(1/result["draw"], 2) if result["draw"] > 0.01 else 99,
|
|
1027
|
+
"away": round(1/result["away_win"], 2) if result["away_win"] > 0.01 else 99,
|
|
1028
|
+
})
|
|
1029
|
+
# Compute HT breakdown from lambda values if not already in result
|
|
1030
|
+
if "ht_lambda_home" not in result:
|
|
1031
|
+
_lh = result.get("lambda_home", 1.2)
|
|
1032
|
+
_la = result.get("lambda_away", 1.0)
|
|
1033
|
+
_ht_frac = 0.42
|
|
1034
|
+
_ht_lh = max(0.1, _lh * _ht_frac)
|
|
1035
|
+
_ht_la = max(0.1, _la * _ht_frac)
|
|
1036
|
+
_st_lh = max(0.1, _lh * (1 - _ht_frac))
|
|
1037
|
+
_st_la = max(0.1, _la * (1 - _ht_frac))
|
|
1038
|
+
_ht_sp: Dict = {}
|
|
1039
|
+
_ht_hw = _ht_dr = _ht_aw = 0.0
|
|
1040
|
+
for _hg in range(9):
|
|
1041
|
+
_ph = _poisson_pmf(_hg, _ht_lh)
|
|
1042
|
+
for _ag in range(9):
|
|
1043
|
+
_pa = _poisson_pmf(_ag, _ht_la)
|
|
1044
|
+
_p = _ph * _pa
|
|
1045
|
+
_ht_sp[(_hg, _ag)] = _p
|
|
1046
|
+
if _hg > _ag: _ht_hw += _p
|
|
1047
|
+
elif _hg == _ag: _ht_dr += _p
|
|
1048
|
+
else: _ht_aw += _p
|
|
1049
|
+
_ht_top = sorted(_ht_sp.items(), key=lambda x: -x[1])[:4]
|
|
1050
|
+
result.update({
|
|
1051
|
+
"ht_lambda_home": round(_ht_lh, 2),
|
|
1052
|
+
"ht_lambda_away": round(_ht_la, 2),
|
|
1053
|
+
"st_lambda_home": round(_st_lh, 2),
|
|
1054
|
+
"st_lambda_away": round(_st_la, 2),
|
|
1055
|
+
"ht_home_win": round(_ht_hw, 3),
|
|
1056
|
+
"ht_draw": round(_ht_dr, 3),
|
|
1057
|
+
"ht_away_win": round(_ht_aw, 3),
|
|
1058
|
+
"ht_top_scorelines": [
|
|
1059
|
+
{"score": f"{hg}-{ag}", "prob": round(p * 100, 1)}
|
|
1060
|
+
for (hg, ag), p in _ht_top
|
|
1061
|
+
],
|
|
1062
|
+
})
|
|
1063
|
+
return result
|
|
1064
|
+
except Exception as _e:
|
|
1065
|
+
logger.debug(f"[predict_wc_match] 新引擎不可用,回落到原模型: {_e}")
|
|
1066
|
+
|
|
1067
|
+
# ── 回落:原 FIFA 静态表 + 纯泊松 ─────────────────────────────────────────
|
|
1068
|
+
hr = _find_fifa_rating(home_team)
|
|
1069
|
+
ar = _find_fifa_rating(away_team)
|
|
1070
|
+
|
|
1071
|
+
# Default to "average team" if not in table
|
|
1072
|
+
default_r = {"attack": 1.20, "defense": 0.87, "ranking": "?", "name": "", "key": home_team}
|
|
1073
|
+
if not hr:
|
|
1074
|
+
hr = {**default_r, "name": team_display_name(home_team, "zh"), "key": home_team.lower()}
|
|
1075
|
+
if not ar:
|
|
1076
|
+
ar = {**default_r, "name": team_display_name(away_team, "zh"), "key": away_team.lower()}
|
|
1077
|
+
|
|
1078
|
+
# Try to calibrate from actual WC results if available
|
|
1079
|
+
wc_data = _get("/competitions/WC/matches", {"status": "FINISHED"})
|
|
1080
|
+
league_avg = 1.35 # WC tends to be lower scoring than club football
|
|
1081
|
+
wc_finished = []
|
|
1082
|
+
if wc_data:
|
|
1083
|
+
for m in wc_data.get("matches", []):
|
|
1084
|
+
ft = m.get("score", {}).get("fullTime", {})
|
|
1085
|
+
hg = ft.get("home")
|
|
1086
|
+
ag = ft.get("away")
|
|
1087
|
+
if hg is not None and ag is not None:
|
|
1088
|
+
wc_finished.append((hg, ag))
|
|
1089
|
+
if wc_finished:
|
|
1090
|
+
total_g = sum(h + a for h, a in wc_finished)
|
|
1091
|
+
league_avg = total_g / (len(wc_finished) * 2)
|
|
1092
|
+
|
|
1093
|
+
# Home advantage: hosts get 1.12, neutral venue = 1.0
|
|
1094
|
+
home_key_low = hr["key"].lower()
|
|
1095
|
+
if neutral_venue and home_key_low not in _WC_HOST_NATIONS:
|
|
1096
|
+
home_adv = 1.0
|
|
1097
|
+
elif home_key_low in _WC_HOST_NATIONS:
|
|
1098
|
+
home_adv = 1.12
|
|
1099
|
+
else:
|
|
1100
|
+
home_adv = 1.18 # non-WC club match
|
|
1101
|
+
|
|
1102
|
+
# Expected goals
|
|
1103
|
+
lambda_home = hr["attack"] * ar["defense"] * home_adv * league_avg
|
|
1104
|
+
lambda_away = ar["attack"] * hr["defense"] * league_avg
|
|
1105
|
+
|
|
1106
|
+
lambda_home = max(0.3, min(lambda_home, 6.0))
|
|
1107
|
+
lambda_away = max(0.3, min(lambda_away, 6.0))
|
|
1108
|
+
|
|
1109
|
+
# Scoreline matrix
|
|
1110
|
+
max_goals = 9
|
|
1111
|
+
score_probs: Dict = {}
|
|
1112
|
+
home_win = draw = away_win = 0.0
|
|
1113
|
+
|
|
1114
|
+
for hg in range(max_goals):
|
|
1115
|
+
ph = _poisson_pmf(hg, lambda_home)
|
|
1116
|
+
for ag in range(max_goals):
|
|
1117
|
+
pa = _poisson_pmf(ag, lambda_away)
|
|
1118
|
+
p = ph * pa
|
|
1119
|
+
score_probs[(hg, ag)] = p
|
|
1120
|
+
if hg > ag:
|
|
1121
|
+
home_win += p
|
|
1122
|
+
elif hg == ag:
|
|
1123
|
+
draw += p
|
|
1124
|
+
else:
|
|
1125
|
+
away_win += p
|
|
1126
|
+
|
|
1127
|
+
top_scores = sorted(score_probs.items(), key=lambda x: -x[1])[:6]
|
|
1128
|
+
btts = 1.0 - _poisson_pmf(0, lambda_home) - _poisson_pmf(0, lambda_away) + _poisson_pmf(0, lambda_home) * _poisson_pmf(0, lambda_away)
|
|
1129
|
+
|
|
1130
|
+
# Half-time prediction: ~42% of goals scored in first 45 min
|
|
1131
|
+
_ht_frac = 0.42
|
|
1132
|
+
ht_lh = max(0.1, lambda_home * _ht_frac)
|
|
1133
|
+
ht_la = max(0.1, lambda_away * _ht_frac)
|
|
1134
|
+
st_lh = max(0.1, lambda_home * (1 - _ht_frac))
|
|
1135
|
+
st_la = max(0.1, lambda_away * (1 - _ht_frac))
|
|
1136
|
+
ht_score_probs: Dict = {}
|
|
1137
|
+
ht_hw = ht_dr = ht_aw = 0.0
|
|
1138
|
+
for hg in range(max_goals):
|
|
1139
|
+
ph = _poisson_pmf(hg, ht_lh)
|
|
1140
|
+
for ag in range(max_goals):
|
|
1141
|
+
pa = _poisson_pmf(ag, ht_la)
|
|
1142
|
+
p = ph * pa
|
|
1143
|
+
ht_score_probs[(hg, ag)] = p
|
|
1144
|
+
if hg > ag: ht_hw += p
|
|
1145
|
+
elif hg == ag: ht_dr += p
|
|
1146
|
+
else: ht_aw += p
|
|
1147
|
+
ht_top = sorted(ht_score_probs.items(), key=lambda x: -x[1])[:4]
|
|
1148
|
+
|
|
1149
|
+
def implied(p: float) -> float:
|
|
1150
|
+
return round(1 / p, 2) if p > 0.01 else 99.0
|
|
1151
|
+
|
|
1152
|
+
result = {
|
|
1153
|
+
"home_team": home_team,
|
|
1154
|
+
"away_team": away_team,
|
|
1155
|
+
"home_name_cn": team_display_name(hr.get("name", home_team), "zh"),
|
|
1156
|
+
"away_name_cn": team_display_name(ar.get("name", away_team), "zh"),
|
|
1157
|
+
"home_name_en": team_display_name(home_team, "en"),
|
|
1158
|
+
"away_name_en": team_display_name(away_team, "en"),
|
|
1159
|
+
"home_ranking": hr.get("ranking", "?"),
|
|
1160
|
+
"away_ranking": ar.get("ranking", "?"),
|
|
1161
|
+
"home_attack": round(hr["attack"], 2),
|
|
1162
|
+
"away_attack": round(ar["attack"], 2),
|
|
1163
|
+
"home_defense": round(hr["defense"], 2),
|
|
1164
|
+
"away_defense": round(ar["defense"], 2),
|
|
1165
|
+
"lambda_home": round(lambda_home, 2),
|
|
1166
|
+
"lambda_away": round(lambda_away, 2),
|
|
1167
|
+
"home_win": round(home_win, 3),
|
|
1168
|
+
"draw": round(draw, 3),
|
|
1169
|
+
"away_win": round(away_win, 3),
|
|
1170
|
+
"btts": round(btts, 3),
|
|
1171
|
+
"league_avg_goals": round(league_avg, 2),
|
|
1172
|
+
"calibrated_matches": len(wc_finished),
|
|
1173
|
+
"home_adv": home_adv,
|
|
1174
|
+
"top_scorelines": [
|
|
1175
|
+
{"score": f"{hg}-{ag}", "prob": round(p * 100, 1)}
|
|
1176
|
+
for (hg, ag), p in top_scores
|
|
1177
|
+
],
|
|
1178
|
+
"implied_odds": {
|
|
1179
|
+
"home": implied(home_win),
|
|
1180
|
+
"draw": implied(draw),
|
|
1181
|
+
"away": implied(away_win),
|
|
1182
|
+
},
|
|
1183
|
+
# Half-time / second-half breakdown
|
|
1184
|
+
"ht_lambda_home": round(ht_lh, 2),
|
|
1185
|
+
"ht_lambda_away": round(ht_la, 2),
|
|
1186
|
+
"st_lambda_home": round(st_lh, 2),
|
|
1187
|
+
"st_lambda_away": round(st_la, 2),
|
|
1188
|
+
"ht_home_win": round(ht_hw, 3),
|
|
1189
|
+
"ht_draw": round(ht_dr, 3),
|
|
1190
|
+
"ht_away_win": round(ht_aw, 3),
|
|
1191
|
+
"ht_top_scorelines": [
|
|
1192
|
+
{"score": f"{hg}-{ag}", "prob": round(p * 100, 1)}
|
|
1193
|
+
for (hg, ag), p in ht_top
|
|
1194
|
+
],
|
|
1195
|
+
}
|
|
1196
|
+
result["data_quality"] = football_prediction_quality(result)
|
|
1197
|
+
return result
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def format_prediction_block(pred: Dict, match_info: Optional[Dict] = None) -> str:
|
|
1201
|
+
"""
|
|
1202
|
+
Format a prediction dict (from predict_wc_match or predict_match)
|
|
1203
|
+
into a rich text block suitable for LLM context injection.
|
|
1204
|
+
"""
|
|
1205
|
+
ht = pred["home_team"]
|
|
1206
|
+
at = pred["away_team"]
|
|
1207
|
+
ht_cn = team_display_name(pred.get("home_name_cn", ht), "zh")
|
|
1208
|
+
at_cn = team_display_name(pred.get("away_name_cn", at), "zh")
|
|
1209
|
+
lh = pred["lambda_home"]
|
|
1210
|
+
la = pred["lambda_away"]
|
|
1211
|
+
hw = pred["home_win"]
|
|
1212
|
+
dr = pred["draw"]
|
|
1213
|
+
aw = pred["away_win"]
|
|
1214
|
+
bt = pred.get("btts", 0)
|
|
1215
|
+
|
|
1216
|
+
lines = []
|
|
1217
|
+
if match_info:
|
|
1218
|
+
ts = match_info.get("date", "")
|
|
1219
|
+
status = match_info.get("status", "")
|
|
1220
|
+
stage = match_info.get("stage", "")
|
|
1221
|
+
score = match_info.get("score")
|
|
1222
|
+
score_str = f" **{score}**" if score else (" [已完赛]" if status == "FINISHED" else " [待开赛]")
|
|
1223
|
+
lines.append(f"\n【比赛信息】{ts} | {stage}")
|
|
1224
|
+
lines.append(f" {ht} vs {at}{score_str}")
|
|
1225
|
+
|
|
1226
|
+
# Display-width-aware padding for CJK names (each CJK char = 2 terminal columns)
|
|
1227
|
+
def _disp_width(s: str) -> int:
|
|
1228
|
+
w = 0
|
|
1229
|
+
for c in s:
|
|
1230
|
+
w += 2 if '一' <= c <= '鿿' or ' ' <= c <= '〿' else 1
|
|
1231
|
+
return w
|
|
1232
|
+
|
|
1233
|
+
def _short(name: str, maxlen: int = 6) -> str:
|
|
1234
|
+
out, w = "", 0
|
|
1235
|
+
for c in name:
|
|
1236
|
+
cw = 2 if '一' <= c <= '鿿' or ' ' <= c <= '〿' else 1
|
|
1237
|
+
if w + cw > maxlen * 2:
|
|
1238
|
+
break
|
|
1239
|
+
out += c; w += cw
|
|
1240
|
+
return out
|
|
1241
|
+
|
|
1242
|
+
def _pad(s: str, target_cols: int) -> str:
|
|
1243
|
+
return s + " " * max(0, target_cols - _disp_width(s))
|
|
1244
|
+
|
|
1245
|
+
ht_s = _short(ht_cn)
|
|
1246
|
+
at_s = _short(at_cn)
|
|
1247
|
+
|
|
1248
|
+
# ── 近期状态 & 数据完整度 ──────────────────────────────────────────────────
|
|
1249
|
+
home_form = pred.get("home_form", "?????")
|
|
1250
|
+
away_form = pred.get("away_form", "?????")
|
|
1251
|
+
home_momentum = pred.get("home_momentum", "stable")
|
|
1252
|
+
away_momentum = pred.get("away_momentum", "stable")
|
|
1253
|
+
_MOM = {"rising": "↑上升", "declining": "↓下滑", "stable": "→平稳"}
|
|
1254
|
+
|
|
1255
|
+
has_form = home_form and home_form != "?????"
|
|
1256
|
+
has_h2h = bool(pred.get("h2h_advantage", 0) or pred.get("total_matches", 0))
|
|
1257
|
+
|
|
1258
|
+
# ── 模型标签 ──────────────────────────────────────────────────────────────
|
|
1259
|
+
model_tag = pred.get("model", "Dixon-Coles+Poisson")
|
|
1260
|
+
if not has_form and not has_h2h:
|
|
1261
|
+
# strip absent modules from tag to avoid misleading claim
|
|
1262
|
+
model_tag = "Elo+Dixon-Coles"
|
|
1263
|
+
|
|
1264
|
+
lines.append(f"\n【泊松模型量化预测 — {ht_cn} vs {at_cn}】")
|
|
1265
|
+
lines.append(f" 模型: {model_tag}")
|
|
1266
|
+
if not has_form:
|
|
1267
|
+
lines.append(" ⚠ 无近期战绩数据,预测基于 Elo 排名强度估算")
|
|
1268
|
+
quality = pred.get("data_quality") or football_prediction_quality(pred)
|
|
1269
|
+
if quality.get("missing"):
|
|
1270
|
+
labels = football_quality_missing_labels(quality["missing"], "zh")
|
|
1271
|
+
lines.append(f" 数据质量: {quality.get('status', 'estimated')} · 缺失/估算: {', '.join(labels)}")
|
|
1272
|
+
lines.append("")
|
|
1273
|
+
|
|
1274
|
+
# ── 队伍强度行(含 Elo)────────────────────────────────────────────────────
|
|
1275
|
+
hr_num = pred.get("home_ranking", "?")
|
|
1276
|
+
ar_num = pred.get("away_ranking", "?")
|
|
1277
|
+
h_elo = pred.get("home_elo")
|
|
1278
|
+
a_elo = pred.get("away_elo")
|
|
1279
|
+
elo_h = f" Elo {h_elo:.0f}" if h_elo else ""
|
|
1280
|
+
elo_a = f" Elo {a_elo:.0f}" if a_elo else ""
|
|
1281
|
+
|
|
1282
|
+
def _fmt_val(v) -> str:
|
|
1283
|
+
try:
|
|
1284
|
+
return f"{float(v):.2f}"
|
|
1285
|
+
except Exception:
|
|
1286
|
+
return str(v)
|
|
1287
|
+
|
|
1288
|
+
def _rank_text(v) -> str:
|
|
1289
|
+
return f"FIFA #{v}" if v not in (None, "", "?") else "FIFA 排名缺失"
|
|
1290
|
+
|
|
1291
|
+
lines.append(f" 主队 {_pad(ht_cn, 12)} 进攻 {_fmt_val(pred.get('home_attack','?'))} 防守 {_fmt_val(pred.get('home_defense','?'))} {_rank_text(hr_num)}{elo_h}")
|
|
1292
|
+
lines.append(f" 客队 {_pad(at_cn, 12)} 进攻 {_fmt_val(pred.get('away_attack','?'))} 防守 {_fmt_val(pred.get('away_defense','?'))} {_rank_text(ar_num)}{elo_a}")
|
|
1293
|
+
lines.append(f" 预期进球: {ht_cn} {lh:.2f} | {at_cn} {la:.2f} (赛事场均 {pred.get('league_avg_goals', 1.35):.2f} 球)")
|
|
1294
|
+
lines.append("")
|
|
1295
|
+
|
|
1296
|
+
# ── 赔率框 ────────────────────────────────────────────────────────────────
|
|
1297
|
+
_COL = 14
|
|
1298
|
+
lines.append(f" ┌{'─'*48}┐")
|
|
1299
|
+
lines.append(f" │ {_pad(ht_s, _COL)}获胜: {hw*100:5.1f}% 赔率: {pred['implied_odds']['home']:5.2f} │")
|
|
1300
|
+
lines.append(f" │ {_pad('平局', _COL)} {dr*100:5.1f}% 赔率: {pred['implied_odds']['draw']:5.2f} │")
|
|
1301
|
+
lines.append(f" │ {_pad(at_s, _COL)}获胜: {aw*100:5.1f}% 赔率: {pred['implied_odds']['away']:5.2f} │")
|
|
1302
|
+
lines.append(f" └{'─'*48}┘")
|
|
1303
|
+
lines.append("")
|
|
1304
|
+
|
|
1305
|
+
# ── 比分概率条形图 ────────────────────────────────────────────────────────
|
|
1306
|
+
lines.append(" 候选比分(top_scorelines,按概率降序):")
|
|
1307
|
+
top_prob = max((sc["prob"] for sc in pred["top_scorelines"]), default=1)
|
|
1308
|
+
for sc in pred["top_scorelines"]:
|
|
1309
|
+
bar_len = max(1, round(sc["prob"] / top_prob * 14))
|
|
1310
|
+
bar = "▓" * bar_len
|
|
1311
|
+
lines.append(f" {sc['score']} ({sc['prob']:5.1f}%) {bar}")
|
|
1312
|
+
lines.append("")
|
|
1313
|
+
|
|
1314
|
+
o25 = pred.get("over_2_5", 0)
|
|
1315
|
+
lines.append(f" 双方均进球 (BTTS): {bt*100:.1f}% 进球超 2.5: {o25*100:.1f}%")
|
|
1316
|
+
|
|
1317
|
+
# ── H2H ──────────────────────────────────────────────────────────────────
|
|
1318
|
+
h2h_summary = pred.get("h2h_summary", "")
|
|
1319
|
+
lines.append("")
|
|
1320
|
+
if h2h_summary and "无历史数据" not in h2h_summary:
|
|
1321
|
+
# Replace English team keys with CN names for consistent display
|
|
1322
|
+
h2h_disp = h2h_summary.replace(ht, ht_cn).replace(at, at_cn)
|
|
1323
|
+
lines.append(f" 历史对阵: {h2h_disp}")
|
|
1324
|
+
else:
|
|
1325
|
+
lines.append(f" 历史对阵: {ht_cn} vs {at_cn} — 暂无历史交锋记录")
|
|
1326
|
+
|
|
1327
|
+
# ── 近期状态 ──────────────────────────────────────────────────────────────
|
|
1328
|
+
lines.append("")
|
|
1329
|
+
if has_form:
|
|
1330
|
+
mom_h = _MOM.get(home_momentum, "→平稳")
|
|
1331
|
+
lines.append(f" {ht_cn} 近期状态: {home_form} {mom_h}")
|
|
1332
|
+
else:
|
|
1333
|
+
lines.append(f" {ht_cn} 近期状态: 暂无数据")
|
|
1334
|
+
|
|
1335
|
+
away_form_real = away_form and away_form != "?????"
|
|
1336
|
+
if away_form_real:
|
|
1337
|
+
mom_a = _MOM.get(away_momentum, "→平稳")
|
|
1338
|
+
lines.append(f" {at_cn} 近期状态: {away_form} {mom_a}")
|
|
1339
|
+
else:
|
|
1340
|
+
lines.append(f" {at_cn} 近期状态: 暂无数据")
|
|
1341
|
+
|
|
1342
|
+
# ── 结论 ──────────────────────────────────────────────────────────────────
|
|
1343
|
+
gap = abs(hw - aw)
|
|
1344
|
+
top_sc = pred["top_scorelines"][0]["score"] if pred.get("top_scorelines") else "?"
|
|
1345
|
+
if hw > aw and gap > 0.40:
|
|
1346
|
+
outlook = f"{ht_cn} 强势主导(胜率 {hw*100:.0f}%),最可能比分 {top_sc}。"
|
|
1347
|
+
elif hw > aw and gap > 0.15:
|
|
1348
|
+
outlook = f"{ht_cn} 占据优势(胜率 {hw*100:.0f}%),但平局概率 {dr*100:.0f}% 不可忽视。"
|
|
1349
|
+
elif aw > hw and gap > 0.40:
|
|
1350
|
+
outlook = f"{at_cn} 强势主导(胜率 {aw*100:.0f}%),最可能比分 {top_sc}。"
|
|
1351
|
+
elif aw > hw and gap > 0.15:
|
|
1352
|
+
outlook = f"{at_cn} 占据优势(胜率 {aw*100:.0f}%),但平局概率 {dr*100:.0f}% 不可忽视。"
|
|
1353
|
+
elif hw > aw:
|
|
1354
|
+
outlook = f"{ht_cn} 微弱优势,平局概率 {dr*100:.0f}% 最高,双方实力接近。"
|
|
1355
|
+
elif aw > hw:
|
|
1356
|
+
outlook = f"{at_cn} 微弱优势,平局概率 {dr*100:.0f}% 最高,比赛走势难以预判。"
|
|
1357
|
+
else:
|
|
1358
|
+
outlook = f"双方实力相当,平局概率最高({dr*100:.0f}%)。"
|
|
1359
|
+
|
|
1360
|
+
data_note = ""
|
|
1361
|
+
if not has_form:
|
|
1362
|
+
data_note = "(配置 football-data.org API key 可获取近期战绩以提升精度)"
|
|
1363
|
+
lines.append(f"\n 【预测结论】{outlook}")
|
|
1364
|
+
if data_note:
|
|
1365
|
+
lines.append(f" {data_note}")
|
|
1366
|
+
lines.append(" 提示:准确比分概率通常较分散,请按候选区间参考,不构成投注建议。")
|
|
1367
|
+
|
|
1368
|
+
return "\n".join(lines)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
# Chinese → English team name mapping for World Cup teams
|
|
1372
|
+
_CN_TEAM_MAP: Dict[str, str] = {
|
|
1373
|
+
# 亚洲
|
|
1374
|
+
"卡塔尔": "qatar", "카타르": "qatar",
|
|
1375
|
+
"日本": "japan", "韩国": "south korea", "朝鲜": "north korea",
|
|
1376
|
+
"沙特": "saudi arabia", "沙特阿拉伯": "saudi arabia",
|
|
1377
|
+
"伊朗": "iran", "伊拉克": "iraq", "约旦": "jordan",
|
|
1378
|
+
"澳大利亚": "australia", "中国": "china", "中国队": "china",
|
|
1379
|
+
"越南": "vietnam", "泰国": "thailand", "印尼": "indonesia",
|
|
1380
|
+
"巴林": "bahrain", "阿联酋": "united arab emirates", "阿曼": "oman",
|
|
1381
|
+
"科威特": "kuwait", "叙利亚": "syria",
|
|
1382
|
+
# 欧洲
|
|
1383
|
+
"英格兰": "england", "法国": "france", "德国": "germany",
|
|
1384
|
+
"西班牙": "spain", "意大利": "italy", "葡萄牙": "portugal",
|
|
1385
|
+
"荷兰": "netherlands", "比利时": "belgium", "丹麦": "denmark",
|
|
1386
|
+
"波兰": "poland", "克罗地亚": "croatia", "瑞士": "switzerland",
|
|
1387
|
+
"乌克兰": "ukraine", "塞尔维亚": "serbia", "匈牙利": "hungary",
|
|
1388
|
+
"奥地利": "austria", "苏格兰": "scotland", "威尔士": "wales",
|
|
1389
|
+
"北爱尔兰": "northern ireland", "瑞典": "sweden", "挪威": "norway",
|
|
1390
|
+
"芬兰": "finland", "捷克": "czechia", "斯洛伐克": "slovakia",
|
|
1391
|
+
"罗马尼亚": "romania", "保加利亚": "bulgaria", "希腊": "greece",
|
|
1392
|
+
"土耳其": "turkey", "俄罗斯": "russia", "乌兹别克斯坦": "uzbekistan",
|
|
1393
|
+
# 北中美洲
|
|
1394
|
+
"美国": "united states", "加拿大": "canada", "墨西哥": "mexico",
|
|
1395
|
+
"哥斯达黎加": "costa rica", "巴拿马": "panama", "洪都拉斯": "honduras",
|
|
1396
|
+
"牙买加": "jamaica", "特立尼达": "trinidad",
|
|
1397
|
+
# 南美洲
|
|
1398
|
+
"阿根廷": "argentina", "巴西": "brazil", "乌拉圭": "uruguay",
|
|
1399
|
+
"哥伦比亚": "colombia", "厄瓜多尔": "ecuador", "智利": "chile",
|
|
1400
|
+
"秘鲁": "peru", "巴拉圭": "paraguay", "玻利维亚": "bolivia",
|
|
1401
|
+
"委内瑞拉": "venezuela",
|
|
1402
|
+
# 非洲
|
|
1403
|
+
"摩洛哥": "morocco", "塞内加尔": "senegal", "尼日利亚": "nigeria",
|
|
1404
|
+
"加纳": "ghana", "喀麦隆": "cameroon", "科特迪瓦": "ivory coast",
|
|
1405
|
+
"突尼斯": "tunisia", "埃及": "egypt", "阿尔及利亚": "algeria",
|
|
1406
|
+
"南非": "south africa", "马里": "mali", "布基纳法索": "burkina faso",
|
|
1407
|
+
# 大洋洲
|
|
1408
|
+
"新西兰": "new zealand",
|
|
1409
|
+
# 波黑
|
|
1410
|
+
"波黑": "bosnia", "波斯尼亚": "bosnia",
|
|
1411
|
+
# 加勒比海 / 中北美
|
|
1412
|
+
"库拉索": "curacao", "库拉索岛": "curacao", "库拉所": "curacao",
|
|
1413
|
+
"库加索": "curacao",
|
|
1414
|
+
"科索沃": "kosovo", "科索保": "kosovo",
|
|
1415
|
+
"特多": "trinidad", "特立尼达和多巴哥": "trinidad",
|
|
1416
|
+
"海地": "haiti", "古巴": "cuba", "百慕大": "bermuda",
|
|
1417
|
+
"格林纳达": "grenada", "安提瓜": "antigua",
|
|
1418
|
+
"圭亚那": "guyana", "苏里南": "suriname",
|
|
1419
|
+
"危地马拉": "guatemala", "萨尔瓦多": "el salvador",
|
|
1420
|
+
"尼加拉瓜": "nicaragua", "伯利兹": "belize",
|
|
1421
|
+
# 非洲补充
|
|
1422
|
+
"刚果": "dr congo", "刚果金": "dr congo", "刚果民主共和国": "dr congo",
|
|
1423
|
+
"刚果布": "congo", "刚果河": "congo", "刚果共和国": "republic of congo",
|
|
1424
|
+
"科摩罗": "comoros", "厄立特里亚": "eritrea",
|
|
1425
|
+
"莫桑比克": "mozambique", "津巴布韦": "zimbabwe",
|
|
1426
|
+
"赞比亚": "zambia", "坦桑尼亚": "tanzania",
|
|
1427
|
+
"肯尼亚": "kenya", "埃塞俄比亚": "ethiopia",
|
|
1428
|
+
"利比亚": "libya", "苏丹": "sudan",
|
|
1429
|
+
"几内亚": "guinea", "几内亚比绍": "guinea-bissau",
|
|
1430
|
+
"佛得角": "cape verde",
|
|
1431
|
+
# 亚洲补充
|
|
1432
|
+
"菲律宾": "philippines", "马来西亚": "malaysia",
|
|
1433
|
+
"新加坡": "singapore", "缅甸": "myanmar",
|
|
1434
|
+
"黎巴嫩": "lebanon", "约旦": "jordan",
|
|
1435
|
+
"吉尔吉斯": "kyrgyzstan", "塔吉克斯坦": "tajikistan",
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
# Words that appear in Chinese football queries but are NOT team names
|
|
1439
|
+
_TEAM_EXTRACTION_STOPWORDS = frozenset({
|
|
1440
|
+
"分析", "比赛", "预测", "开球", "谁先", "以及", "足球", "世界杯",
|
|
1441
|
+
"欧冠", "英超", "德甲", "西甲", "意甲", "法甲", "结果", "比分",
|
|
1442
|
+
"情况", "今天", "今日", "明天", "明日", "本场", "这场", "哪队",
|
|
1443
|
+
"赢球", "进球", "胜利", "失败", "赔率", "胜率", "概率",
|
|
1444
|
+
"谁会", "谁能", "谁将", "先进", "先开", "获胜", "谁赢",
|
|
1445
|
+
"预计", "推测", "如何", "怎么", "怎样", "多少", "几比几",
|
|
1446
|
+
"the", "and", "vs", "for", "who", "will", "win", "score",
|
|
1447
|
+
"predict", "analysis", "analyze", "match", "game", "today",
|
|
1448
|
+
})
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def get_sports_context_for_query(query: str) -> str:
|
|
1452
|
+
"""
|
|
1453
|
+
Auto-detect sports query intent and fetch relevant live data + auto-run
|
|
1454
|
+
Poisson quantitative prediction when a match prediction intent is detected.
|
|
1455
|
+
Returns a formatted context string for LLM injection.
|
|
1456
|
+
"""
|
|
1457
|
+
low = query.lower()
|
|
1458
|
+
lines = []
|
|
1459
|
+
|
|
1460
|
+
# Intent detection
|
|
1461
|
+
is_wc = any(k in low for k in ("世界杯", "world cup", "worldcup", "wc"))
|
|
1462
|
+
is_live = any(k in low for k in ("直播", "实时", "live", "今天", "今日", "现在"))
|
|
1463
|
+
is_predict = any(k in low for k in (
|
|
1464
|
+
"预测", "分析", "谁赢", "谁会赢", "谁能赢", "胜率", "概率",
|
|
1465
|
+
"比分", "结果", "赔率", "predict", "analysis", "analyze",
|
|
1466
|
+
"who wins", "who will win", "odds", "preview",
|
|
1467
|
+
))
|
|
1468
|
+
|
|
1469
|
+
# Extract team names — priority: _CN_TEAM_MAP exact matches, then tokenized remainder
|
|
1470
|
+
team_hints: List[str] = []
|
|
1471
|
+
# 1. Dictionary-based extraction (most reliable)
|
|
1472
|
+
for cn, en in _CN_TEAM_MAP.items():
|
|
1473
|
+
if cn in query:
|
|
1474
|
+
team_hints.append(en)
|
|
1475
|
+
|
|
1476
|
+
# 2. Tokenize remaining text with comprehensive Chinese separators
|
|
1477
|
+
_sep_query = query
|
|
1478
|
+
for sep in ("跟", "和", "与", "对", "对阵", "对战", "vs", "VS", "Vs",
|
|
1479
|
+
"pk", "PK", "versus", "对决", " "):
|
|
1480
|
+
_sep_query = _sep_query.replace(sep, " ")
|
|
1481
|
+
|
|
1482
|
+
# 动词前缀:这些词粘在队名前面需要剥离,如"分析德国"→"德国"
|
|
1483
|
+
_VERB_PREFIXES = (
|
|
1484
|
+
"分析", "预测", "查看", "研究", "看看", "帮我", "帮忙", "比较",
|
|
1485
|
+
"看下", "看一下", "告诉我", "请问", "analyze", "predict", "check",
|
|
1486
|
+
)
|
|
1487
|
+
_INLINE_STOPWORDS = ("预测", "比分", "开球", "以及", "足球", "谁", "赢", "的", "会")
|
|
1488
|
+
|
|
1489
|
+
for word in _sep_query.split():
|
|
1490
|
+
clean = word.strip("?!,。、《》()[]【】::'\"的")
|
|
1491
|
+
if len(clean) < 2 or len(clean) > 20:
|
|
1492
|
+
continue
|
|
1493
|
+
|
|
1494
|
+
# 剥离动词前缀,如"分析德国" → "德国"
|
|
1495
|
+
for vp in _VERB_PREFIXES:
|
|
1496
|
+
if clean.startswith(vp) and len(clean) > len(vp) + 1:
|
|
1497
|
+
_stripped = clean[len(vp):]
|
|
1498
|
+
# 只有剥离后剩余部分是已知队名或可识别词才替换
|
|
1499
|
+
if _stripped in _CN_TEAM_MAP or len(_stripped) >= 2:
|
|
1500
|
+
clean = _stripped
|
|
1501
|
+
break
|
|
1502
|
+
|
|
1503
|
+
# 救援逻辑:token 包含内联停用词(如"库拉索比分谁赢"),尝试提取队名
|
|
1504
|
+
rescued = False
|
|
1505
|
+
if any(sw in clean for sw in _INLINE_STOPWORDS):
|
|
1506
|
+
for cn, en in _CN_TEAM_MAP.items():
|
|
1507
|
+
# 支持队名在 token 开头或结尾
|
|
1508
|
+
if (clean.startswith(cn) or clean.endswith(cn)) and en not in team_hints:
|
|
1509
|
+
team_hints.append(en)
|
|
1510
|
+
rescued = True
|
|
1511
|
+
break
|
|
1512
|
+
if not rescued:
|
|
1513
|
+
# 没有找到队名,但 clean 本身如果是已知队名就保留
|
|
1514
|
+
if clean in _CN_TEAM_MAP:
|
|
1515
|
+
en = _CN_TEAM_MAP[clean]
|
|
1516
|
+
if en not in team_hints:
|
|
1517
|
+
team_hints.append(en)
|
|
1518
|
+
continue
|
|
1519
|
+
|
|
1520
|
+
if clean.lower() in _TEAM_EXTRACTION_STOPWORDS:
|
|
1521
|
+
continue
|
|
1522
|
+
|
|
1523
|
+
# 已被字典提取覆盖则跳过(避免重复以中文/英文两种形式出现)
|
|
1524
|
+
_en = _CN_TEAM_MAP.get(clean, "")
|
|
1525
|
+
if _en and _en in team_hints:
|
|
1526
|
+
continue
|
|
1527
|
+
|
|
1528
|
+
if clean not in team_hints and clean.lower() not in team_hints:
|
|
1529
|
+
team_hints.append(_en or clean.lower())
|
|
1530
|
+
|
|
1531
|
+
team_hints = list(dict.fromkeys(team_hints)) # deduplicate, preserve order
|
|
1532
|
+
|
|
1533
|
+
if is_wc:
|
|
1534
|
+
wc_matches = get_tournament_matches("WC")
|
|
1535
|
+
match_info: Optional[Dict] = None
|
|
1536
|
+
|
|
1537
|
+
if wc_matches:
|
|
1538
|
+
lines.append("【FIFA 世界杯 2026 赛事数据】")
|
|
1539
|
+
|
|
1540
|
+
# Find matches for mentioned teams
|
|
1541
|
+
relevant = []
|
|
1542
|
+
for hint in team_hints:
|
|
1543
|
+
hint_low = hint.lower()
|
|
1544
|
+
for m in wc_matches:
|
|
1545
|
+
if (hint_low in (m.get("home") or "").lower() or
|
|
1546
|
+
hint_low in (m.get("away") or "").lower()):
|
|
1547
|
+
if m not in relevant:
|
|
1548
|
+
relevant.append(m)
|
|
1549
|
+
|
|
1550
|
+
if not relevant:
|
|
1551
|
+
# Show recent results + next few upcoming
|
|
1552
|
+
recent = [m for m in wc_matches if m.get("score")][-5:]
|
|
1553
|
+
scheduled = [m for m in wc_matches if not m.get("score")][:5]
|
|
1554
|
+
relevant = recent + scheduled
|
|
1555
|
+
|
|
1556
|
+
for m in relevant[:8]:
|
|
1557
|
+
score_str = f" **{m['score']}**" if m.get("score") else ""
|
|
1558
|
+
ht_str = f" (半场 {m['ht_score']})" if m.get("ht_score") and m.get("score") else ""
|
|
1559
|
+
lines.append(f" {m['date']} | {m.get('stage','')} | "
|
|
1560
|
+
f"{m['home']} vs {m['away']}{score_str}{ht_str} [{m['status']}]")
|
|
1561
|
+
|
|
1562
|
+
# pick match_info for the prediction block (prefer the most relevant upcoming)
|
|
1563
|
+
if relevant:
|
|
1564
|
+
upcoming = [m for m in relevant if m.get("status") not in ("FINISHED",)]
|
|
1565
|
+
match_info = upcoming[0] if upcoming else relevant[0]
|
|
1566
|
+
|
|
1567
|
+
else:
|
|
1568
|
+
lines.append("【世界杯数据】football-data.org 暂未开放此赛事的免费访问。")
|
|
1569
|
+
|
|
1570
|
+
# --- Auto quantitative prediction ---
|
|
1571
|
+
if is_predict and len(team_hints) >= 2:
|
|
1572
|
+
# Find the two most likely teams from query
|
|
1573
|
+
api_home = team_hints[0]
|
|
1574
|
+
api_away = team_hints[1]
|
|
1575
|
+
|
|
1576
|
+
# Try to get display names from match data
|
|
1577
|
+
display_home = api_home
|
|
1578
|
+
display_away = api_away
|
|
1579
|
+
if match_info:
|
|
1580
|
+
display_home = match_info.get("home", api_home)
|
|
1581
|
+
display_away = match_info.get("away", api_away)
|
|
1582
|
+
# Re-map so prediction uses the actual API name
|
|
1583
|
+
api_home = display_home
|
|
1584
|
+
api_away = display_away
|
|
1585
|
+
|
|
1586
|
+
try:
|
|
1587
|
+
pred = predict_wc_match(api_home, api_away, neutral_venue=True)
|
|
1588
|
+
block = format_prediction_block(pred, match_info=match_info)
|
|
1589
|
+
lines.append(block)
|
|
1590
|
+
except Exception as exc:
|
|
1591
|
+
logger.warning("WC predict_wc_match failed: %s", exc)
|
|
1592
|
+
# Fallback: try with just FIFA rating keys
|
|
1593
|
+
try:
|
|
1594
|
+
pred = predict_wc_match(team_hints[0], team_hints[1], neutral_venue=True)
|
|
1595
|
+
lines.append(format_prediction_block(pred))
|
|
1596
|
+
except Exception:
|
|
1597
|
+
pass
|
|
1598
|
+
|
|
1599
|
+
elif is_live:
|
|
1600
|
+
live = get_live_scores()
|
|
1601
|
+
if live:
|
|
1602
|
+
lines.append(f"【实时比分 — {len(live)} 场进行中】")
|
|
1603
|
+
for m in live[:10]:
|
|
1604
|
+
lines.append(f" {m['competition']} | {m['home']} {m.get('score','')} {m['away']} [{m['status']}]")
|
|
1605
|
+
else:
|
|
1606
|
+
today = get_todays_matches()
|
|
1607
|
+
if today:
|
|
1608
|
+
lines.append(f"【今日赛程 ({datetime.utcnow().strftime('%Y-%m-%d')})】")
|
|
1609
|
+
for m in today[:10]:
|
|
1610
|
+
score_str = f" {m['score']}" if m.get("score") else ""
|
|
1611
|
+
lines.append(f" {m['competition']} | {m['home']} vs {m['away']}{score_str} [{m['status']}]")
|
|
1612
|
+
|
|
1613
|
+
# ── Unconditional Poisson prediction when predict intent + 2 teams found ──
|
|
1614
|
+
# Runs even when "世界杯" is NOT in the query (covers "预测今天加拿大跟波黑" etc.)
|
|
1615
|
+
_already_has_pred = any("泊松模型量化预测" in l for l in lines)
|
|
1616
|
+
if is_predict and len(team_hints) >= 2 and not _already_has_pred:
|
|
1617
|
+
_t1, _t2 = team_hints[0], team_hints[1]
|
|
1618
|
+
if _find_fifa_rating(_t1) or _find_fifa_rating(_t2):
|
|
1619
|
+
try:
|
|
1620
|
+
pred = predict_wc_match(_t1, _t2, neutral_venue=True)
|
|
1621
|
+
lines.append(format_prediction_block(pred))
|
|
1622
|
+
except Exception as _exc:
|
|
1623
|
+
logger.warning("fallback predict_wc_match failed: %s", _exc)
|
|
1624
|
+
|
|
1625
|
+
return "\n".join(lines)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
# ── understat xG data (no API key) ────────────────────────────────────────────
|
|
1629
|
+
|
|
1630
|
+
async def get_xg_data(team: str, league_name: str = "EPL") -> Optional[Dict]:
|
|
1631
|
+
"""
|
|
1632
|
+
Fetch xG data via understat (async). Requires: pip install understat
|
|
1633
|
+
league_name: EPL / La_liga / Bundesliga / Serie_A / Ligue_1 / RFPL
|
|
1634
|
+
"""
|
|
1635
|
+
try:
|
|
1636
|
+
import understat
|
|
1637
|
+
async with understat.Understat() as us:
|
|
1638
|
+
league_map = {
|
|
1639
|
+
"epl": "EPL", "pl": "EPL", "英超": "EPL",
|
|
1640
|
+
"bundesliga": "Bundesliga", "bl": "Bundesliga", "德甲": "Bundesliga",
|
|
1641
|
+
"laliga": "La_liga", "pd": "La_liga", "西甲": "La_liga",
|
|
1642
|
+
"seriea": "Serie_A", "sa": "Serie_A", "意甲": "Serie_A",
|
|
1643
|
+
"ligue1": "Ligue_1", "fl1": "Ligue_1", "法甲": "Ligue_1",
|
|
1644
|
+
}
|
|
1645
|
+
ul = league_map.get(league_name.lower(), league_name)
|
|
1646
|
+
teams = await us.get_teams(ul, 2024)
|
|
1647
|
+
target = next((t for t in teams if team.lower() in t["title"].lower()), None)
|
|
1648
|
+
if not target:
|
|
1649
|
+
return None
|
|
1650
|
+
team_data = await us.get_team_results(target["id"], 2024)
|
|
1651
|
+
xg_list = [
|
|
1652
|
+
{
|
|
1653
|
+
"date": m.get("datetime", "")[:10],
|
|
1654
|
+
"h": m.get("h", {}).get("title"),
|
|
1655
|
+
"a": m.get("a", {}).get("title"),
|
|
1656
|
+
"xg_h": round(float(m.get("xG", {}).get("h", 0)), 2),
|
|
1657
|
+
"xg_a": round(float(m.get("xG", {}).get("a", 0)), 2),
|
|
1658
|
+
"goals_h": m.get("goals", {}).get("h"),
|
|
1659
|
+
"goals_a": m.get("goals", {}).get("a"),
|
|
1660
|
+
}
|
|
1661
|
+
for m in team_data[:10]
|
|
1662
|
+
]
|
|
1663
|
+
avg_xg = round(sum(x["xg_h"] for x in xg_list) / len(xg_list), 2) if xg_list else None
|
|
1664
|
+
return {"team": team, "xg_matches": xg_list, "avg_xg_10": avg_xg}
|
|
1665
|
+
except ImportError:
|
|
1666
|
+
logger.info("understat not installed: pip install understat (xG数据不可用)")
|
|
1667
|
+
return None
|
|
1668
|
+
except Exception as exc:
|
|
1669
|
+
logger.warning("understat xG fetch failed: %s", exc)
|
|
1670
|
+
return None
|