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.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
@@ -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