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,1276 @@
1
+ """
2
+ MarketCommandsMixin — Market commands: quote, realty, football, screen, news, screen_cn, limitup, north.
3
+
4
+ Extracted from aria_cli.py. Methods' __globals__ are rebound to aria_cli's namespace
5
+ by _rebind_mixin_globals() called at module load time.
6
+ """
7
+ from __future__ import annotations
8
+ from typing import Optional, Tuple
9
+
10
+
11
+ _FOOTBALL_CONNECTORS = (
12
+ "对阵", "对战", "对决", " vs ", " VS ", "vs", "VS", " v.s. ",
13
+ " versus ", "跟", "和", "与", "对", "pk", "PK",
14
+ )
15
+
16
+ _FOOTBALL_STRONG_INTENT_TERMS = (
17
+ "比分", "比赛预测", "比赛", "对阵", "交手", "胜负", "几比几",
18
+ "进球", "足球", "国家队", "世界杯", "欧洲杯", "欧冠", "英超",
19
+ "西甲", "德甲", "意甲", "法甲", "中超", "美职联",
20
+ "打败", "战胜", "击败", "打平", "晋级", "出线", "夺冠", "踢",
21
+ "score", "match", "football", "soccer", "beat",
22
+ )
23
+
24
+ _FOOTBALL_AMBIGUOUS_INTENT_TERMS = (
25
+ "预测", "谁赢", "谁能赢", "谁会赢", "结果预测",
26
+ "predict", "prediction", "win",
27
+ )
28
+
29
+ _MARKET_CONTEXT_TERMS = (
30
+ "股票", "股价", "成交量", "市值", "行情", "k线", "K线", "图表",
31
+ "技术指标", "均线", "支撑", "阻力", "涨跌", "涨幅", "跌幅",
32
+ "财报", "财务", "估值", "营收", "利润", "持仓", "风险", "基金",
33
+ "ETF", "etf", "期权", "债券", "外汇", "期货", "RSI", "MACD",
34
+ "买入", "卖出", "做多", "做空", "quote", "stock", "share",
35
+ "equity", "volume", "market cap", "earnings", "revenue", "price",
36
+ )
37
+
38
+
39
+ def _rss_items_from_xml(xml_text: str, limit: int = 5) -> list[dict]:
40
+ """Parse simple RSS item fields without external dependencies."""
41
+ import html as _html
42
+ import xml.etree.ElementTree as _ET
43
+
44
+ try:
45
+ root = _ET.fromstring(xml_text)
46
+ except Exception:
47
+ return []
48
+ items: list[dict] = []
49
+ for item in root.findall(".//item"):
50
+ title = item.findtext("title") or ""
51
+ link = item.findtext("link") or ""
52
+ pub_date = item.findtext("pubDate") or item.findtext("published") or ""
53
+ source = item.findtext("source") or ""
54
+ if not title:
55
+ continue
56
+ items.append({
57
+ "title": _html.unescape(title.strip()),
58
+ "url": link.strip(),
59
+ "published_at": pub_date.strip(),
60
+ "source": source.strip() or "RSS",
61
+ })
62
+ if len(items) >= limit:
63
+ break
64
+ return items
65
+
66
+
67
+ def _fetch_public_news_fallback(topic: str, limit: int = 5) -> list[dict]:
68
+ """Fetch public RSS news without API keys.
69
+
70
+ Yahoo Finance works well for tickers; Google News RSS covers private
71
+ companies such as SpaceX. This is a best-effort fallback, not a guaranteed
72
+ research source.
73
+ """
74
+ import re as _re
75
+ import urllib.parse as _parse
76
+ import urllib.request as _request
77
+
78
+ topic = (topic or "market").strip()
79
+ urls: list[str] = []
80
+ if _re.match(r"^[A-Z]{1,6}(?:[.-][A-Z]{1,3})?$", topic):
81
+ urls.append(
82
+ "https://feeds.finance.yahoo.com/rss/2.0/headline?"
83
+ f"s={_parse.quote(topic)}&region=US&lang=en-US"
84
+ )
85
+ query = f"{topic} latest news when:14d"
86
+ urls.append(
87
+ "https://news.google.com/rss/search?"
88
+ f"q={_parse.quote(query)}&hl=en-US&gl=US&ceid=US:en"
89
+ )
90
+ headers = {"User-Agent": "Mozilla/5.0 AriaCode/4.1"}
91
+ for url in urls:
92
+ try:
93
+ req = _request.Request(url, headers=headers)
94
+ with _request.urlopen(req, timeout=8) as resp:
95
+ text = resp.read().decode("utf-8", errors="replace")
96
+ items = _rss_items_from_xml(text, limit=limit)
97
+ if items:
98
+ return items
99
+ except Exception:
100
+ continue
101
+ return []
102
+
103
+
104
+ def _is_known_football_name(name: str) -> bool:
105
+ """Return True only when a fragment resolves to a known football team/country."""
106
+ n = (name or "").strip()
107
+ if not n:
108
+ return False
109
+ try:
110
+ from football_data_client import _CN_TEAM_MAP, _FIFA_RATINGS
111
+ except Exception:
112
+ return False
113
+ if n in _CN_TEAM_MAP:
114
+ return True
115
+ nl = n.lower()
116
+ for cn, en in _CN_TEAM_MAP.items():
117
+ if n == cn or nl == str(en).lower():
118
+ return True
119
+ for en_key, data in _FIFA_RATINGS.items():
120
+ if nl == str(en_key).lower() or n == str(data.get("name", "")):
121
+ return True
122
+ return False
123
+
124
+
125
+ def _is_probable_football_query(text: str, pair: Optional[Tuple[str, str]] = None) -> bool:
126
+ """Guard the NL football route so finance queries do not enter Poisson mode."""
127
+ raw = text or ""
128
+ if not raw.strip() or raw.strip().startswith("/"):
129
+ return False
130
+ if any(term in raw for term in _MARKET_CONTEXT_TERMS):
131
+ return False
132
+ pair = pair or _parse_nl_team_pair(raw)
133
+ if not pair:
134
+ return False
135
+ known_pair = _is_known_football_name(pair[0]) and _is_known_football_name(pair[1])
136
+ if any(term in raw for term in _FOOTBALL_STRONG_INTENT_TERMS):
137
+ return True
138
+ if any(term in raw for term in _FOOTBALL_AMBIGUOUS_INTENT_TERMS):
139
+ return known_pair
140
+ if not any(conn.lower() in raw.lower() for conn in _FOOTBALL_CONNECTORS):
141
+ return False
142
+ return known_pair
143
+
144
+
145
+ def _parse_nl_team_pair(text: str) -> Optional[Tuple[str, str]]:
146
+ """
147
+ Extract (home_cn, away_cn) from a natural-language football query.
148
+
149
+ Handles patterns like:
150
+ "葡萄牙和刚果比赛比分预测"
151
+ "巴西跟阿根廷谁赢"
152
+ "英格兰对阵法国"
153
+ "Germany vs France prediction" ← English also supported
154
+ Returns None if two teams cannot be confidently identified.
155
+ """
156
+ try:
157
+ from football_data_client import _CN_TEAM_MAP, _FIFA_RATINGS
158
+ except Exception:
159
+ return None
160
+
161
+ # Build reverse map: english_lower → cn_name (from _CN_TEAM_MAP values)
162
+ _EN_TO_CN: dict = {}
163
+ for cn, en in _CN_TEAM_MAP.items():
164
+ _EN_TO_CN.setdefault(en.lower(), cn)
165
+ # Also add direct FIFA rating keys → cn name
166
+ for en_key, data in _FIFA_RATINGS.items():
167
+ cn_name = data.get("name", "")
168
+ if cn_name and en_key.lower() not in _EN_TO_CN:
169
+ _EN_TO_CN[en_key.lower()] = cn_name
170
+
171
+ # Ordered connectors — longer ones first to avoid partial matches
172
+ _CONNECTORS = _FOOTBALL_CONNECTORS
173
+ # Words to strip from team-name fragments
174
+ _STRIP_WORDS = (
175
+ "预测", "分析", "比赛", "比分", "胜率", "结果", "谁赢", "谁会赢",
176
+ "今天", "今日", "明天", "的", "了", "吗", "呢",
177
+ "prediction", "match", "game", "preview", "who wins", "predict",
178
+ "football", "soccer", "score",
179
+ )
180
+
181
+ def _clean(s: str) -> str:
182
+ s = s.strip("?!,。、《》()[]【】::'\"-— \t")
183
+ for w in sorted(_STRIP_WORDS, key=len, reverse=True):
184
+ s = s.replace(w, "").strip()
185
+ return s.strip()
186
+
187
+ def _resolve(name: str) -> Optional[str]:
188
+ """Resolve a name (CN or EN) to its canonical Chinese name."""
189
+ name = name.strip()
190
+ if not name:
191
+ return None
192
+ # Direct CN lookup
193
+ if name in _CN_TEAM_MAP:
194
+ return name
195
+ # English → CN
196
+ nl = name.lower()
197
+ if nl in _EN_TO_CN:
198
+ return _EN_TO_CN[nl]
199
+ # Partial English match
200
+ for en_key, cn_n in _EN_TO_CN.items():
201
+ if nl in en_key or en_key in nl:
202
+ return cn_n
203
+ # Partial CN match
204
+ for cn in _CN_TEAM_MAP:
205
+ if name in cn or cn in name:
206
+ return cn
207
+ # Return as-is if it looks like a real name (≥2 chars)
208
+ return name if len(name) >= 2 else None
209
+
210
+ # ── Approach 1: split on connector ───────────────────────────────────────
211
+ for conn in _CONNECTORS:
212
+ if conn.lower() in text.lower():
213
+ idx = text.lower().index(conn.lower())
214
+ left = _resolve(_clean(text[:idx]))
215
+ right = _resolve(_clean(text[idx + len(conn):]))
216
+ if left and right and left != right:
217
+ return left, right
218
+
219
+ # ── Approach 2: scan for all known Chinese team names in text order ──────
220
+ found: list = []
221
+ for cn in _CN_TEAM_MAP:
222
+ if cn in text:
223
+ found.append((text.index(cn), cn))
224
+ # Also scan English names (word-boundary, case-insensitive)
225
+ import re as _re
226
+ tl = text.lower()
227
+ for en_key, cn_n in _EN_TO_CN.items():
228
+ if len(en_key) < 3:
229
+ continue
230
+ m = _re.search(r'\b' + _re.escape(en_key) + r'\b', tl)
231
+ if m:
232
+ found.append((m.start(), cn_n))
233
+ found.sort()
234
+ # Remove duplicates keeping earlier occurrence
235
+ seen_en: set = set()
236
+ unique: list = []
237
+ for pos, cn in found:
238
+ en = _CN_TEAM_MAP.get(cn, cn)
239
+ if en not in seen_en:
240
+ seen_en.add(en)
241
+ unique.append((pos, cn))
242
+ if len(unique) >= 2:
243
+ return unique[0][1], unique[1][1]
244
+
245
+ return None
246
+
247
+
248
+ class MarketCommandsMixin:
249
+ """Mixin: Market commands: quote, realty, football, screen, news, screen_cn, limitup, north."""
250
+
251
+ async def cmd_realty(self, args: str):
252
+ """
253
+ /realty market [city1] [city2] — 城市房价指数
254
+ /realty reit [code] — REIT 列表或单只分析
255
+ /realty valuation — 物业估值计算器(交互式)
256
+ /realty rent — 租金收益率计算(交互式)
257
+ /realty compare [cities...] — 多城市对比
258
+ /realty score — 资产区位评分(交互式)
259
+ /realty us — 美国住房数据
260
+ """
261
+ import asyncio as _asyncio
262
+ loop = _asyncio.get_event_loop()
263
+ parts = args.strip().split() if args.strip() else []
264
+ sub = parts[0].lower() if parts else "market"
265
+
266
+ try:
267
+ from realty_data_tools import (
268
+ get_house_price_index, get_re_investment,
269
+ get_reits_list, get_reit_analysis, get_multi_city_comparison,
270
+ calc_rental_yield, property_valuation, asset_location_score,
271
+ get_us_housing_data,
272
+ )
273
+ except ImportError as e:
274
+ if HAS_RICH:
275
+ console.print(f"[red]realty_data_tools 未加载: {e}[/red]")
276
+ return
277
+
278
+ if sub == "market":
279
+ city1 = parts[1] if len(parts) > 1 else "北京"
280
+ city2 = parts[2] if len(parts) > 2 else ("上海" if city1 != "上海" else "北京")
281
+ import functools as _functools
282
+ if HAS_RICH:
283
+ with console.status(f"[dim]获取 {city1}/{city2} 房价指数...[/dim]", spinner="dots"):
284
+ r = await loop.run_in_executor(
285
+ None, _functools.partial(get_house_price_index, city1, city2)
286
+ )
287
+ else:
288
+ r = get_house_price_index(city1, city2)
289
+ _render_house_price(r)
290
+ # Also show investment data
291
+ if HAS_RICH:
292
+ with console.status("[dim]获取房地产投资数据...[/dim]", spinner="dots"):
293
+ ri = await loop.run_in_executor(None, get_re_investment)
294
+ else:
295
+ ri = get_re_investment()
296
+ if ri.get("success") and ri.get("latest"):
297
+ lt = ri["latest"]
298
+ if HAS_RICH:
299
+ console.print(f"\n [dim]房地产开发投资[/dim] {lt.get('日期','')} "
300
+ f"最新值 [bold]{lt.get('最新值','')}[/bold] "
301
+ f"涨跌 {lt.get('涨跌幅','')} "
302
+ f"近1年 {lt.get('近1年涨跌幅','')}")
303
+
304
+ elif sub == "reit":
305
+ code = parts[1] if len(parts) > 1 else None
306
+ if code:
307
+ if HAS_RICH:
308
+ with console.status(f"[dim]分析 {code} REIT...[/dim]", spinner="dots"):
309
+ r = await loop.run_in_executor(None, get_reit_analysis, code)
310
+ else:
311
+ r = get_reit_analysis(code)
312
+ if r.get("success"):
313
+ if HAS_RICH:
314
+ console.print(f"\n [bold cyan]{r.get('code','')}[/bold cyan] "
315
+ f"[dim]{r.get('name','')}[/dim]")
316
+ console.print(f" 现价 [bold]{r.get('price','')}[/bold] "
317
+ f"涨跌 {r.get('chg_pct','')}%")
318
+ if r.get("return_1y") is not None:
319
+ rc = "green" if r["return_1y"] > 0 else "red"
320
+ console.print(f" 近1年收益: [{rc}]{r['return_1y']:+.2f}%[/{rc}]")
321
+ if r.get("volatility_annual"):
322
+ console.print(f" 年化波动率: {r['volatility_annual']:.2f}%")
323
+ else:
324
+ console.print(f"[red]{r.get('error','分析失败')}[/red]") if HAS_RICH else None
325
+ else:
326
+ if HAS_RICH:
327
+ with console.status("[dim]获取 REIT 列表...[/dim]", spinner="dots"):
328
+ r = await loop.run_in_executor(None, get_reits_list)
329
+ else:
330
+ r = get_reits_list()
331
+ _render_reits_list(r)
332
+
333
+ elif sub == "compare":
334
+ cities = parts[1:] if len(parts) > 1 else None
335
+ if HAS_RICH:
336
+ with console.status("[dim]对比多城市房价...[/dim]", spinner="dots"):
337
+ r = await loop.run_in_executor(None, get_multi_city_comparison, cities)
338
+ else:
339
+ r = get_multi_city_comparison(cities)
340
+ _render_multi_city(r)
341
+
342
+ elif sub in ("rent", "rental"):
343
+ if HAS_RICH:
344
+ console.print("[bold]💰 租金收益率计算器[/bold] [dim](输入 0 跳过可选项)[/dim]")
345
+ price_wan = _prompt_float("购入价格(万元): ", 200.0)
346
+ monthly_rent = _prompt_float("月租金(元): ", 5000.0)
347
+ annual_costs = _prompt_float("年维护成本(元)[可选]: ", 0.0)
348
+ loan_ratio = _prompt_float("贷款成数 0-1 (如0.7=七成)[可选]: ", 0.0)
349
+ p = {"purchase_price": price_wan, "monthly_rent": monthly_rent,
350
+ "annual_costs": annual_costs, "loan_ratio": loan_ratio}
351
+ r = calc_rental_yield(p)
352
+ _render_rental_yield(r)
353
+
354
+ elif sub in ("valuation", "val"):
355
+ if HAS_RICH:
356
+ console.print("[bold]🏢 物业估值计算器[/bold]")
357
+ area = _prompt_float("建筑面积(㎡): ", 100.0)
358
+ monthly_rent = _prompt_float("月租金(元): ", 5000.0)
359
+ tier = _prompt_str("区位层级 (tier1/tier2/tier3): ", "tier2")
360
+ p = {"area_sqm": area, "monthly_rent": monthly_rent, "location_tier": tier}
361
+ r = property_valuation(p)
362
+ _render_property_val(r)
363
+
364
+ elif sub == "score":
365
+ if HAS_RICH:
366
+ console.print("[bold]📍 资产区位评分[/bold]")
367
+ city = _prompt_str("城市: ", "上海")
368
+ area = _prompt_float("建筑面积(㎡): ", 100.0)
369
+ floor_n = int(_prompt_float("楼层: ", 1.0))
370
+ traffic = _prompt_str("客流量 (high/medium/low): ", "medium")
371
+ fire_ok = _prompt_str("允许明火? (y/n): ", "n").lower() in ("y","yes","是")
372
+ reno_ok = _prompt_str("允许改造? (y/n): ", "y").lower() in ("y","yes","是")
373
+ p = {"city": city, "area_sqm": area, "floor": floor_n,
374
+ "foot_traffic": traffic, "open_fire_allowed": fire_ok,
375
+ "renovation_allowed": reno_ok}
376
+ r = asset_location_score(p)
377
+ _render_asset_score(r)
378
+
379
+ elif sub == "us":
380
+ if HAS_RICH:
381
+ with console.status("[dim]获取美国住房数据...[/dim]", spinner="dots"):
382
+ r = await loop.run_in_executor(None, get_us_housing_data)
383
+ else:
384
+ r = get_us_housing_data()
385
+ if not r.get("success"):
386
+ if HAS_RICH: console.print(f"[red]{r.get('error')}[/red]")
387
+ return
388
+ if HAS_RICH:
389
+ from rich.table import Table as _T
390
+ from rich import box as _box
391
+ tb = _T(title="[bold]🏠 美国住房市场数据[/bold]", box=_box.ROUNDED)
392
+ tb.add_column("指标", style="dim"); tb.add_column("最新值"); tb.add_column("日期", style="dim")
393
+ for key, val in r.get("data", {}).items():
394
+ lt = val.get("latest", {})
395
+ v = lt.get("value")
396
+ tb.add_row(val.get("label", key), str(v) if v else "—", str(lt.get("date",""))[:7])
397
+ console.print(tb)
398
+ for line in r.get("assessment", []):
399
+ console.print(f" [dim]▸ {line}[/dim]")
400
+
401
+ else:
402
+ if HAS_RICH:
403
+ console.print("[dim]用法: /realty [market|reit|compare|rent|valuation|score|us][/dim]")
404
+ console.print("[dim]示例: /realty market 北京 上海[/dim]")
405
+ console.print("[dim] /realty reit 508603[/dim]")
406
+ console.print("[dim] /realty rent (交互式租金计算)[/dim]")
407
+ console.print("[dim] /realty compare 北京 上海 成都 杭州[/dim]")
408
+
409
+ async def cmd_football(self, args: str):
410
+ """
411
+ 足球赛事分析和预测
412
+
413
+ 子命令:
414
+ /football standings <联赛> 联赛积分榜
415
+ /football fixtures <联赛> [days] 近期赛程(默认7天)
416
+ /football predict <主队> vs <客队> [联赛] 比赛预测
417
+ /football team <球队名> [联赛] 球队近期状态
418
+ /football h2h <队1> vs <队2> [联赛] 历史交锋
419
+
420
+ 联赛代码: pl/epl/英超 bl/德甲 ll/西甲 sa/意甲 fl1/法甲 cl/欧冠
421
+ 示例:
422
+ /football standings pl
423
+ /football predict Arsenal vs Chelsea pl
424
+ /football team Manchester City pl
425
+ /football fixtures cl 14
426
+ """
427
+ from rich.table import Table
428
+ from rich import box as rich_box
429
+ from rich.panel import Panel
430
+
431
+ parts = args.strip().split()
432
+ if not parts:
433
+ console.print(Panel(
434
+ "[bold]足球分析命令[/bold]\n\n"
435
+ " [cyan]/football standings pl[/cyan] 英超积分榜\n"
436
+ " [cyan]/football fixtures cl 14[/cyan] 欧冠未来14天赛程\n"
437
+ " [cyan]/football predict Arsenal vs Chelsea[/cyan] 预测比赛结果\n"
438
+ " [cyan]/football team Bayern Munich bl[/cyan] 球队近期状态\n"
439
+ " [cyan]/football h2h Barcelona vs Real Madrid[/cyan] 历史交锋\n\n"
440
+ "[dim]联赛: pl/英超 bl/德甲 ll/西甲 sa/意甲 fl1/法甲 cl/欧冠[/dim]\n"
441
+ "[dim]需要设置 FOOTBALL_DATA_API_KEY(football-data.org 免费注册)[/dim]",
442
+ title="[bold]⚽ Football Analyst[/bold]",
443
+ border_style="green",
444
+ ))
445
+ return
446
+
447
+ sub = parts[0].lower()
448
+
449
+ # ── standings ──────────────────────────────────────────────────────────
450
+ if sub == "standings":
451
+ league = parts[1] if len(parts) > 1 else "pl"
452
+ await self._run_in_executor(_football_standings, league)
453
+
454
+ # ── fixtures ──────────────────────────────────────────────────────────
455
+ elif sub in ("fixtures", "schedule", "赛程"):
456
+ league = parts[1] if len(parts) > 1 else "pl"
457
+ days = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 7
458
+ await self._run_in_executor(_football_fixtures, league, days)
459
+
460
+ # ── predict ───────────────────────────────────────────────────────────
461
+ elif sub in ("predict", "预测", "prediction"):
462
+ raw = " ".join(parts[1:])
463
+ if " vs " in raw.lower():
464
+ idx = raw.lower().index(" vs ")
465
+ home = raw[:idx].strip()
466
+ rest = raw[idx + 4:].strip()
467
+ away_parts = rest.split()
468
+ # last token might be league code (including wc/世界杯)
469
+ from football_data_client import LEAGUE_IDS, TOURNAMENT_CODES
470
+ _all_codes = {**LEAGUE_IDS, **{k: v for k, v in TOURNAMENT_CODES.items()}}
471
+ if away_parts and away_parts[-1].lower().replace(" ", "") in _all_codes:
472
+ league = away_parts[-1]
473
+ away = " ".join(away_parts[:-1])
474
+ elif away_parts and away_parts[-1].lower() in ("wc", "worldcup", "世界杯", "ca", "ec"):
475
+ league = away_parts[-1]
476
+ away = " ".join(away_parts[:-1])
477
+ else:
478
+ league = "pl"
479
+ away = rest
480
+ await self._football_predict(home, away, league)
481
+ else:
482
+ console.print("[red]用法: /football predict <主队> vs <客队> [联赛/wc][/red]")
483
+
484
+ # ── team ──────────────────────────────────────────────────────────────
485
+ elif sub in ("team", "球队"):
486
+ rest = " ".join(parts[1:])
487
+ from football_data_client import LEAGUE_IDS
488
+ tokens = rest.split()
489
+ if tokens and tokens[-1].lower() in LEAGUE_IDS:
490
+ league = tokens[-1]
491
+ team = " ".join(tokens[:-1])
492
+ else:
493
+ league = "pl"
494
+ team = rest
495
+ await self._run_in_executor(_football_team, team, league)
496
+
497
+ # ── h2h ───────────────────────────────────────────────────────────────
498
+ elif sub in ("h2h", "历史", "对决"):
499
+ raw = " ".join(parts[1:])
500
+ if " vs " in raw.lower():
501
+ idx = raw.lower().index(" vs ")
502
+ t1 = raw[:idx].strip()
503
+ rest = raw[idx + 4:].strip()
504
+ from football_data_client import LEAGUE_IDS
505
+ tokens = rest.split()
506
+ if tokens and tokens[-1].lower() in LEAGUE_IDS:
507
+ league = tokens[-1]
508
+ t2 = " ".join(tokens[:-1])
509
+ else:
510
+ league = "pl"
511
+ t2 = rest
512
+ await self._run_in_executor(_football_h2h, t1, t2, league)
513
+ else:
514
+ console.print("[red]用法: /football h2h <队1> vs <队2> [联赛][/red]")
515
+
516
+ else:
517
+ # NL intent: /football 预测加拿大跟波黑... or /football 分析...
518
+ full_args = args.strip()
519
+ _has_cn = any('一' <= c <= '鿿' for c in full_args)
520
+ _has_kw = any(k in full_args.lower() for k in (
521
+ "predict", "preview", "analyze", "analysis", "who wins",
522
+ "预测", "分析", "谁赢", "比分", "胜率", "谁先", "开球",
523
+ ))
524
+ if _has_cn or _has_kw:
525
+ # ── Step 1: Parse two team names from NL text ─────────────────
526
+ _nl_pair = _parse_nl_team_pair(full_args)
527
+ if _nl_pair:
528
+ _h_cn, _a_cn = _nl_pair
529
+ # Determine league: national teams → wc, club → pl default
530
+ try:
531
+ from football_data_client import _CN_TEAM_MAP, _find_fifa_rating
532
+ _h_en = _CN_TEAM_MAP.get(_h_cn, _h_cn)
533
+ _a_en = _CN_TEAM_MAP.get(_a_cn, _a_cn)
534
+ _is_nat = bool(_find_fifa_rating(_h_en) or _find_fifa_rating(_a_en))
535
+ except Exception:
536
+ _is_nat = True
537
+ _nl_league = "wc" if _is_nat else "pl"
538
+ await self._football_predict(_h_cn, _a_cn, _nl_league)
539
+ return
540
+
541
+ # ── Step 2: Fall back to get_sports_context_for_query ─────────
542
+ try:
543
+ from football_data_client import get_sports_context_for_query
544
+ _sports_ctx = get_sports_context_for_query(full_args)
545
+ except Exception:
546
+ _sports_ctx = ""
547
+ if _sports_ctx:
548
+ _has_quant = "量化预测" in _sports_ctx
549
+ _title = "⚽ 赛事预测" if _has_quant else "⚽ 赛事数据"
550
+ console.print(Panel(
551
+ _sports_ctx,
552
+ title=f"[bold]{_title}[/bold]",
553
+ border_style="cyan" if _has_quant else "blue",
554
+ ))
555
+ else:
556
+ console.print(
557
+ "[yellow]⚽ 未能解析队名。[/yellow]\n"
558
+ "支持格式:\n"
559
+ " [cyan]/football predict 葡萄牙 vs 刚果 wc[/cyan]\n"
560
+ " [cyan]/football 葡萄牙和刚果比赛[/cyan] (自动识别)"
561
+ )
562
+ else:
563
+ console.print(f"[red]未知子命令: {sub}[/red] 使用 /football 查看帮助")
564
+
565
+ async def _football_predict(self, home: str, away: str, league: str):
566
+ """Run football match prediction with LLM analysis."""
567
+ from rich.panel import Panel
568
+ from rich.table import Table
569
+ from rich import box as rich_box
570
+ import types
571
+
572
+ console.print(f"[#57606a]⚽ 分析 {home} vs {away} ({league.upper()})…[/#57606a]")
573
+
574
+ # WC / national team prediction path
575
+ _wc_leagues = {"wc", "worldcup", "世界杯", "world_cup", "ca", "ec", "afc"}
576
+ _is_wc = league.lower().replace(" ", "") in _wc_leagues
577
+
578
+ if _is_wc:
579
+ try:
580
+ from football_data_client import (
581
+ predict_wc_match,
582
+ _find_fifa_rating,
583
+ team_display_name,
584
+ football_prediction_quality,
585
+ football_quality_missing_labels,
586
+ )
587
+ raw = predict_wc_match(home, away, neutral_venue=True)
588
+ _h_cn = team_display_name(raw.get("home_name_cn", home), "zh")
589
+ _a_cn = team_display_name(raw.get("away_name_cn", away), "zh")
590
+ # Build strength facts for display
591
+ _h_rank = raw.get("home_ranking", "?")
592
+ _a_rank = raw.get("away_ranking", "?")
593
+ _h_elo = raw.get("home_elo")
594
+ _a_elo = raw.get("away_elo")
595
+ _h_atk = raw.get("home_attack")
596
+ _a_atk = raw.get("away_attack")
597
+ _h_def = raw.get("home_defense")
598
+ _a_def = raw.get("away_defense")
599
+ _h_form = raw.get("home_form", "")
600
+ _a_form = raw.get("away_form", "")
601
+ _cal = raw.get("calibrated_matches", 0)
602
+
603
+ def _rank_label(value):
604
+ return f"#{value}" if value not in (None, "", "?") else "排名缺失"
605
+
606
+ _strength_facts = [
607
+ f"FIFA排名: {_h_cn} {_rank_label(_h_rank)} · {_a_cn} {_rank_label(_a_rank)}",
608
+ ]
609
+ if _h_elo and _a_elo:
610
+ _strength_facts.append(f"Elo评分: {_h_cn} {_h_elo:.0f} · {_a_cn} {_a_elo:.0f}")
611
+ if _h_atk is not None:
612
+ _atk_h = f"{_h_atk:.2f}" if isinstance(_h_atk, float) else str(_h_atk)
613
+ _atk_a = f"{_a_atk:.2f}" if isinstance(_a_atk, float) else str(_a_atk)
614
+ _def_h = f"{_h_def:.2f}" if isinstance(_h_def, float) else str(_h_def)
615
+ _def_a = f"{_a_def:.2f}" if isinstance(_a_def, float) else str(_a_def)
616
+ _strength_facts.append(f"进攻强度: {_h_cn} {_atk_h} · {_a_cn} {_atk_a}")
617
+ _strength_facts.append(f"防守强度: {_h_cn} {_def_h} · {_a_cn} {_def_a}")
618
+ _strength_facts.append(
619
+ f"数据基础: {_cal} 场已完赛 WC 数据校准" if _cal > 0
620
+ else "数据基础: FIFA排名 + Poisson引擎估算"
621
+ )
622
+ _quality = raw.get("data_quality") or football_prediction_quality(raw)
623
+ if _quality.get("missing"):
624
+ _missing_labels = football_quality_missing_labels(_quality["missing"], "zh")
625
+ _strength_facts.append(
626
+ f"数据质量: {_quality.get('status', 'estimated')} · 缺失/估算 {', '.join(_missing_labels)}"
627
+ )
628
+
629
+ pred = types.SimpleNamespace(
630
+ home_win = raw["home_win"],
631
+ draw = raw["draw"],
632
+ away_win = raw["away_win"],
633
+ btts = raw["btts"],
634
+ lambda_home = raw["lambda_home"],
635
+ lambda_away = raw["lambda_away"],
636
+ most_likely = raw["top_scorelines"][0]["score"] if raw["top_scorelines"] else "1-0",
637
+ top_scores = [{"score": s["score"], "prob": s["prob"]} for s in raw["top_scorelines"]],
638
+ implied_odds = raw["implied_odds"],
639
+ key_factors = _strength_facts,
640
+ home_form = _h_form,
641
+ away_form = _a_form,
642
+ home_elo = _h_elo,
643
+ away_elo = _a_elo,
644
+ data_quality = _quality,
645
+ analysis = "",
646
+ verdict = (
647
+ f"[green]预测: {_h_cn} 获胜 ({raw['home_win']:.0%})[/green]" if raw["home_win"] > raw["away_win"] + 0.05
648
+ else f"[green]预测: {_a_cn} 获胜 ({raw['away_win']:.0%})[/green]" if raw["away_win"] > raw["home_win"] + 0.05
649
+ else f"[yellow]预测: 双方势均力敌,平局概率 {raw['draw']:.0%}[/yellow]"
650
+ ),
651
+ ht_lambda_home = raw.get("ht_lambda_home", 0),
652
+ ht_lambda_away = raw.get("ht_lambda_away", 0),
653
+ st_lambda_home = raw.get("st_lambda_home", 0),
654
+ st_lambda_away = raw.get("st_lambda_away", 0),
655
+ ht_home_win = raw.get("ht_home_win", 0),
656
+ ht_draw = raw.get("ht_draw", 0),
657
+ ht_away_win = raw.get("ht_away_win", 0),
658
+ ht_top_scorelines = raw.get("ht_top_scorelines", []),
659
+ home_name_cn = _h_cn,
660
+ away_name_cn = _a_cn,
661
+ )
662
+ except Exception as exc:
663
+ console.print(f"[red]WC 预测失败: {exc}[/red]")
664
+ return
665
+ else:
666
+ try:
667
+ from agents.sports.football_agent import FootballAgent
668
+
669
+ agent = FootballAgent(llm_call=None)
670
+
671
+ import asyncio
672
+ pred = await agent.predict(home, away, league, with_llm=False)
673
+
674
+ # Try LLM enhancement
675
+ if hasattr(self, 'terminal') and self.terminal:
676
+ try:
677
+ _score_candidates = "、".join(
678
+ f"{s['score']}({s['prob']}%)"
679
+ for s in getattr(pred, "top_scores", [])[:5]
680
+ )
681
+ llm_prompt = (
682
+ f"你是专业足球分析师。简洁分析这场比赛(中文,不超过150字):\n"
683
+ f"{home} vs {away}\n"
684
+ f"主队胜: {pred.home_win:.0%} 平: {pred.draw:.0%} 客队胜: {pred.away_win:.0%}\n"
685
+ f"预期进球: {pred.lambda_home:.1f} - {pred.lambda_away:.1f}\n"
686
+ f"最可能比分: {pred.most_likely}\n"
687
+ f"候选比分(按概率降序): {_score_candidates}\n"
688
+ f"关键因素: {'; '.join(pred.key_factors)}\n"
689
+ "规则: 只引用上方概率和候选比分,不要编造射正率、历史交锋、近5场客场等具体数据。"
690
+ )
691
+ analysis_text = await asyncio.wait_for(
692
+ self.terminal._query_llm_async(llm_prompt),
693
+ timeout=30
694
+ )
695
+ if analysis_text:
696
+ pred.analysis = analysis_text
697
+ except Exception:
698
+ pass
699
+
700
+ except Exception as exc:
701
+ console.print(f"[red]预测失败: {exc}[/red]")
702
+ return
703
+
704
+ # ── 确定性分析文字:基于 Poisson 数字生成,不调用 LLM ────────────────
705
+ # 避免 gpt-oss:120b-cloud 忽略 enable_tools=False 并乱用工具
706
+ if not getattr(pred, "analysis", ""):
707
+ _h_n = getattr(pred, "home_name_cn", home)
708
+ _a_n = getattr(pred, "away_name_cn", away)
709
+ _hw = pred.home_win
710
+ _dw = pred.draw
711
+ _aw = pred.away_win
712
+ _lh = pred.lambda_home
713
+ _la = pred.lambda_away
714
+ _ml = pred.most_likely
715
+ # Determine favorite
716
+ if _hw > _aw + 0.08:
717
+ _tend = f"{_h_n} 胜算更大({_hw:.0%}),为本场热门"
718
+ elif _aw > _hw + 0.08:
719
+ _tend = f"{_a_n} 胜算更大({_aw:.0%}),为本场热门"
720
+ else:
721
+ _tend = f"双方势均力敌,{_h_n} 胜/平/负概率分别为 {_hw:.0%}/{_dw:.0%}/{_aw:.0%}"
722
+ # Expected goals narrative
723
+ _total = _lh + _la
724
+ if _total < 2.0:
725
+ _goal_desc = "预计进球偏少,防守型比赛"
726
+ elif _total < 3.0:
727
+ _goal_desc = "进球适中,攻防均衡"
728
+ else:
729
+ _goal_desc = "进球较多,进攻型对决"
730
+ pred.analysis = (
731
+ f"{_tend}。"
732
+ f"Poisson 模型预期进球 {_lh:.1f}–{_la:.1f}(共 {_total:.1f}),"
733
+ f"{_goal_desc},最可能比分为 {_ml}。"
734
+ )
735
+
736
+ # ── display ──────────────────────────────────────────────────────────
737
+ from rich.columns import Columns
738
+ from rich.text import Text
739
+ try:
740
+ from football_data_client import team_display_name as _football_display_name
741
+ except Exception:
742
+ def _football_display_name(value, locale="zh"):
743
+ return str(value or "-")
744
+
745
+ # Probability bars
746
+ _home_display = _football_display_name(getattr(pred, "home_name_cn", home), "zh")
747
+ _away_display = _football_display_name(getattr(pred, "away_name_cn", away), "zh")
748
+
749
+ def pct_bar(val: float, width: int = 12) -> str:
750
+ filled = int(val * width)
751
+ return "█" * filled + "░" * (width - filled)
752
+
753
+ hw_color = "green" if pred.home_win > pred.away_win else "dim"
754
+ aw_color = "green" if pred.away_win > pred.home_win else "dim"
755
+ dw_color = "yellow" if pred.draw > 0.28 else "dim"
756
+
757
+ prob_table = Table(box=rich_box.SIMPLE, show_header=False, padding=(0, 1))
758
+ prob_table.add_column("", style="bold", width=16)
759
+ prob_table.add_column("", width=14)
760
+ prob_table.add_column("", width=6)
761
+ prob_table.add_column("", width=8)
762
+
763
+ prob_table.add_row(
764
+ f"[{hw_color}]{_home_display}[/{hw_color}]",
765
+ f"[{hw_color}]{pct_bar(pred.home_win)}[/{hw_color}]",
766
+ f"[{hw_color}]{pred.home_win:.0%}[/{hw_color}]",
767
+ f"[dim]赔率 {pred.implied_odds['home']}[/dim]",
768
+ )
769
+ prob_table.add_row(
770
+ f"[{dw_color}]平局[/{dw_color}]",
771
+ f"[{dw_color}]{pct_bar(pred.draw)}[/{dw_color}]",
772
+ f"[{dw_color}]{pred.draw:.0%}[/{dw_color}]",
773
+ f"[dim]赔率 {pred.implied_odds['draw']}[/dim]",
774
+ )
775
+ prob_table.add_row(
776
+ f"[{aw_color}]{_away_display}[/{aw_color}]",
777
+ f"[{aw_color}]{pct_bar(pred.away_win)}[/{aw_color}]",
778
+ f"[{aw_color}]{pred.away_win:.0%}[/{aw_color}]",
779
+ f"[dim]赔率 {pred.implied_odds['away']}[/dim]",
780
+ )
781
+
782
+ title = f"⚽ {_home_display} vs {_away_display} [{league.upper()}]"
783
+ console.print(Panel(prob_table, title=f"[bold #3fb950]{title}[/bold #3fb950]", border_style="#3fb950"))
784
+
785
+ console.print(f" [#57606a]预期进球: {_home_display} {pred.lambda_home:.2f} / {_away_display} {pred.lambda_away:.2f}"
786
+ f" │ 双方均进球: {pred.btts:.0%}[/#57606a]")
787
+
788
+ # Top scorelines — show up to 8, colour-coded by outcome
789
+ _h_name = _home_display
790
+ _a_name = _away_display
791
+ if getattr(pred, "top_scores", None):
792
+ score_table = Table(box=rich_box.SIMPLE, show_header=True, padding=(0, 2))
793
+ score_table.add_column("可能比分", style="bold", width=12, no_wrap=True)
794
+ score_table.add_column("概率", justify="right", width=8, no_wrap=True)
795
+ score_table.add_column("结果", width=16, no_wrap=True)
796
+ for s in pred.top_scores[:8]:
797
+ _sc = s["score"]
798
+ _pr = s["prob"]
799
+ try:
800
+ _hg, _ag = (_sc.split("-") + ["0"])[:2]
801
+ _hg, _ag = int(_hg.strip()), int(_ag.strip())
802
+ except Exception:
803
+ _hg, _ag = 0, 0
804
+ if _hg > _ag:
805
+ _label = f"[#3fb950]{_h_name}胜[/#3fb950]"
806
+ _sc_fmt = f"[green]{_sc}[/green]"
807
+ elif _ag > _hg:
808
+ _label = f"[#f85149]{_a_name}胜[/#f85149]"
809
+ _sc_fmt = f"[red]{_sc}[/red]"
810
+ else:
811
+ _label = "[yellow]平局[/yellow]"
812
+ _sc_fmt = f"[yellow]{_sc}[/yellow]"
813
+ score_table.add_row(_sc_fmt, f"{_pr}%", _label)
814
+ console.print(score_table)
815
+
816
+ # Half-time / second-half breakdown
817
+ if getattr(pred, "ht_lambda_home", 0) > 0:
818
+ _h_lbl = getattr(pred, "home_name_cn", home)
819
+ _h_lbl = _football_display_name(_h_lbl, "zh")
820
+ _a_lbl = _football_display_name(getattr(pred, "away_name_cn", away), "zh")
821
+ _ht_best = pred.ht_top_scorelines[0]["score"] if pred.ht_top_scorelines else "0-0"
822
+ _ht_best_p = pred.ht_top_scorelines[0]["prob"] if pred.ht_top_scorelines else 0
823
+ _st_best_lh = getattr(pred, "st_lambda_home", 0)
824
+ _st_best_la = getattr(pred, "st_lambda_away", 0)
825
+ console.print()
826
+ console.print(
827
+ f" [bold]上半场[/bold] 预期进球 {_h_lbl} [cyan]{pred.ht_lambda_home:.2f}[/cyan] / "
828
+ f"{_a_lbl} [cyan]{pred.ht_lambda_away:.2f}[/cyan]"
829
+ f" │ 最可能: [bold]{_ht_best}[/bold] ({_ht_best_p}%)"
830
+ )
831
+ console.print(
832
+ f" [#57606a]上半场胜/平/负: {pred.ht_home_win:.0%} / {pred.ht_draw:.0%} / {pred.ht_away_win:.0%}[/#57606a]"
833
+ )
834
+ _ht_scores_str = " ".join(
835
+ f"[cyan]{s['score']}[/cyan] {s['prob']}%" for s in pred.ht_top_scorelines[:4]
836
+ )
837
+ if _ht_scores_str:
838
+ console.print(f" [#57606a]比分分布: {_ht_scores_str}[/#57606a]")
839
+ console.print(
840
+ f" [bold]下半场[/bold] 预期进球 {_h_lbl} [green]{_st_best_lh:.2f}[/green] / "
841
+ f"{_a_lbl} [green]{_st_best_la:.2f}[/green]"
842
+ f" [#57606a](全场 − 上半场)[/#57606a]"
843
+ )
844
+ console.print()
845
+
846
+ # ── 实力对比 & 近期表现 ───────────────────────────────────────────────
847
+ _h_name_d = _home_display
848
+ _a_name_d = _away_display
849
+ _hform = getattr(pred, "home_form", "")
850
+ _aform = getattr(pred, "away_form", "")
851
+
852
+ if pred.key_factors:
853
+ console.print(f"\n [bold]实力对比[/bold]")
854
+ for f_ in pred.key_factors:
855
+ console.print(f" [#57606a] • {f_}[/#57606a]")
856
+
857
+ # Form strings (W/D/L) from live API — only show when available
858
+ if _hform and _hform not in ("?????", ""):
859
+ def _form_colored(s: str) -> str:
860
+ out = []
861
+ for c in s.upper():
862
+ if c == "W": out.append("[green]W[/green]")
863
+ elif c == "D": out.append("[yellow]D[/yellow]")
864
+ elif c == "L": out.append("[red]L[/red]")
865
+ else: out.append(c)
866
+ return "".join(out)
867
+ console.print(f"\n [bold]近期表现[/bold]")
868
+ console.print(f" {_h_name_d} {_form_colored(_hform)}")
869
+ if _aform and _aform not in ("?????", ""):
870
+ console.print(f" {_a_name_d} {_form_colored(_aform)}")
871
+
872
+ if pred.analysis:
873
+ console.print(Panel(
874
+ pred.analysis,
875
+ title="[bold]量化分析[/bold]",
876
+ border_style="#8c959f",
877
+ padding=(0, 2),
878
+ ))
879
+
880
+ console.print(f"\n [bold green]{pred.verdict}[/bold green]")
881
+ console.print(f" [#6e7781]提示:准确比分概率通常较分散,请按候选区间参考,不构成投注建议。[/#6e7781]\n")
882
+
883
+ async def cmd_screen(self, args: str):
884
+ """股票筛选: CN → screen_ashare; US → yfinance 大盘成分筛选."""
885
+ criteria = args.strip() or ""
886
+ low = criteria.lower()
887
+
888
+ # CN market detection
889
+ _cn_kw = ("a股", "沪深", "创业板", "科创板", "港股", "cn", "ashare", "沪市", "深市")
890
+ _is_cn = any(k in low for k in _cn_kw) or any(c.isdigit() for c in criteria[:6])
891
+
892
+ if _is_cn:
893
+ params: Dict[str, Any] = {}
894
+ for tok in args.split():
895
+ if "=" in tok:
896
+ k, v = tok.split("=", 1)
897
+ params[k.strip()] = v.strip()
898
+ if "screen_ashare" in LOCAL_TOOLS:
899
+ await self._run_local_tool("screen_ashare", params, "A股选股筛选")
900
+ else:
901
+ await self.terminal.send_message(f"帮我筛选A股股票,条件:{criteria or '市值>50亿,非ST,流动性好'}")
902
+ return
903
+
904
+ # US / global: yfinance-based screening on a reference pool
905
+ import asyncio as _asyncio
906
+ _loop = _asyncio.get_event_loop()
907
+
908
+ _US_POOL = [
909
+ "AAPL","MSFT","NVDA","GOOGL","AMZN","META","TSLA","BRK-B","JPM","V",
910
+ "UNH","XOM","JNJ","WMT","MA","PG","LLY","HD","CVX","MRK",
911
+ "ABBV","PEP","KO","AVGO","COST","BAC","TMO","MCD","ACN","ADBE",
912
+ "CRM","NFLX","AMD","TXN","QCOM","INTC","CSCO","WFC","PM","VZ",
913
+ "RTX","HON","AMGN","LIN","DHR","UNP","CAT","SBUX","GS","BA",
914
+ ]
915
+
916
+ # Map common text criteria to filter presets
917
+ _growth_kw = ("growth", "成长", "高增速", "tech", "科技", "ai", "人工智能")
918
+ _value_kw = ("value", "价值", "低估", "dividend", "分红")
919
+ _momentum_kw= ("momentum", "动量", "趋势", "breakout", "突破")
920
+ _is_growth = any(k in low for k in _growth_kw)
921
+ _is_value = any(k in low for k in _value_kw)
922
+ _is_momentum = any(k in low for k in _momentum_kw)
923
+
924
+ def _fetch_pool():
925
+ try:
926
+ import yfinance as _yf
927
+ tickers = _yf.Tickers(" ".join(_US_POOL))
928
+ rows = []
929
+ for sym in _US_POOL:
930
+ try:
931
+ info = tickers.tickers[sym].fast_info
932
+ price = getattr(info, "last_price", None) or 0
933
+ mktcap = getattr(info, "market_cap", None) or 0
934
+ pe = getattr(info, "pe_ratio", None)
935
+ yr_return = getattr(info, "year_change", None)
936
+ rows.append({
937
+ "symbol": sym, "price": price,
938
+ "mktcap": mktcap, "pe": pe,
939
+ "yr_return": yr_return,
940
+ })
941
+ except Exception:
942
+ pass
943
+ return rows
944
+ except Exception as _e:
945
+ logger.debug("screen US fetch error: %s", _e)
946
+ return []
947
+
948
+ if HAS_RICH:
949
+ _status_msg = f"[dim]筛选 {len(_US_POOL)} 只美股 ({criteria or 'top market cap'})…[/dim]"
950
+ with console.status(_status_msg, spinner="dots"):
951
+ rows = await _loop.run_in_executor(None, _fetch_pool)
952
+ else:
953
+ print(" 筛选美股中…")
954
+ rows = await _loop.run_in_executor(None, _fetch_pool)
955
+
956
+ if not rows:
957
+ await self.terminal.send_message(
958
+ f"Screen US stocks matching: {criteria or 'large-cap'}. "
959
+ "Show top 10 with price, P/E, market cap, 1-year return."
960
+ )
961
+ return
962
+
963
+ # Apply simple filters
964
+ if _is_growth:
965
+ rows = [r for r in rows if (r.get("yr_return") or 0) > 0.15]
966
+ elif _is_value:
967
+ rows = [r for r in rows if r.get("pe") and 5 < r["pe"] < 20]
968
+ elif _is_momentum:
969
+ rows = sorted(rows, key=lambda r: r.get("yr_return") or 0, reverse=True)
970
+ else:
971
+ rows = sorted(rows, key=lambda r: r.get("mktcap") or 0, reverse=True)
972
+
973
+ rows = rows[:15]
974
+
975
+ if not rows:
976
+ msg = f"[yellow]当前条件 '{criteria}' 无匹配标的(池: {len(_US_POOL)} 只)[/yellow]"
977
+ console.print(msg) if HAS_RICH else print(msg.replace("[yellow]","").replace("[/yellow]",""))
978
+ return
979
+
980
+ if HAS_RICH:
981
+ from rich.table import Table as _Tbl
982
+ t = _Tbl(title=f"美股筛选 {criteria or 'large-cap'} 共 {len(rows)} 只",
983
+ show_header=True, box=None, padding=(0, 1))
984
+ t.add_column("代码", style="bold", width=8)
985
+ t.add_column("价格", justify="right")
986
+ t.add_column("市值(B$)", justify="right", style="dim")
987
+ t.add_column("PE", justify="right", style="dim")
988
+ t.add_column("年涨跌%", justify="right")
989
+ for r in rows:
990
+ yr = r.get("yr_return")
991
+ yr_s = f"{yr*100:+.1f}%" if yr is not None else "—"
992
+ yr_color = "green" if (yr or 0) >= 0 else "red"
993
+ pe_s = f"{r['pe']:.1f}" if r.get("pe") and r["pe"] == r["pe"] else "—"
994
+ mc_s = f"{r['mktcap']/1e9:.0f}" if (r.get("mktcap") or 0) > 0 else "—"
995
+ t.add_row(
996
+ r["symbol"],
997
+ f"{r['price']:.2f}" if r.get("price") else "—",
998
+ mc_s, pe_s,
999
+ f"[{yr_color}]{yr_s}[/{yr_color}]",
1000
+ )
1001
+ console.print(t)
1002
+ console.print(f" [dim]来源: yfinance · 池: {len(_US_POOL)} 只大市值美股[/dim]")
1003
+ else:
1004
+ print(f" 美股筛选 {criteria}")
1005
+ for r in rows:
1006
+ yr = r.get("yr_return")
1007
+ yr_s = f"{yr*100:+.1f}%" if yr is not None else "—"
1008
+ print(f" {r['symbol']:<8} ${r.get('price',0):.2f} {yr_s}")
1009
+
1010
+ async def cmd_news(self, args: str):
1011
+ """Fetch latest financial news for a topic or symbol.
1012
+
1013
+ Usage: /news [topic|symbol] [--limit N]
1014
+ Examples:
1015
+ /news AAPL
1016
+ /news earnings --limit 10
1017
+ /news crypto --limit 3
1018
+ """
1019
+ parts = args.split()
1020
+ limit = 5
1021
+ topic_parts = []
1022
+ i = 0
1023
+ while i < len(parts):
1024
+ if parts[i] == "--limit" and i + 1 < len(parts):
1025
+ try:
1026
+ limit = max(1, min(20, int(parts[i + 1])))
1027
+ i += 2
1028
+ continue
1029
+ except ValueError:
1030
+ pass
1031
+ topic_parts.append(parts[i])
1032
+ i += 1
1033
+ topic = " ".join(topic_parts) or "market"
1034
+
1035
+ console.print(f"[dim]Fetching {limit} news items for '{topic}'...[/dim]" if HAS_RICH
1036
+ else f"Fetching news for {topic}...")
1037
+
1038
+ # Try backend first, then local tools (Finnhub / NewsAPI / AKShare fallback chain)
1039
+ result = await execute_aria_tool(self.terminal.api_url, "analyze_news", {
1040
+ "query": topic, "limit": limit,
1041
+ })
1042
+ if not result.get("success") and "analyze_news" in LOCAL_TOOLS:
1043
+ # Local fallback: uses Finnhub → NewsAPI → AKShare depending on configured keys
1044
+ local_fn = LOCAL_TOOLS["analyze_news"][0]
1045
+ result = await asyncio.get_event_loop().run_in_executor(
1046
+ None, local_fn, {"query": topic, "symbol": topic, "limit": limit}
1047
+ )
1048
+ if result.get("success"):
1049
+ data = result.get("data", {})
1050
+ if isinstance(data, dict):
1051
+ articles = data.get("articles", data.get("news", []))
1052
+ sentiment = data.get("sentiment", data.get("overall_sentiment", ""))
1053
+ elif isinstance(data, list):
1054
+ articles = data
1055
+ sentiment = ""
1056
+ else:
1057
+ articles = []
1058
+ sentiment = ""
1059
+ if not (isinstance(articles, list) and articles):
1060
+ articles = await asyncio.get_event_loop().run_in_executor(
1061
+ None, _fetch_public_news_fallback, topic, limit
1062
+ )
1063
+ if articles:
1064
+ sentiment = "public RSS fallback"
1065
+ if isinstance(articles, list) and articles:
1066
+ if HAS_RICH:
1067
+ console.print()
1068
+ if sentiment:
1069
+ sent_color = "green" if "positive" in sentiment.lower() or "bullish" in sentiment.lower() else (
1070
+ "red" if "negative" in sentiment.lower() or "bearish" in sentiment.lower() else "yellow"
1071
+ )
1072
+ console.print(f" Sentiment: [{sent_color}]{sentiment}[/{sent_color}]")
1073
+ console.print()
1074
+ for idx, a in enumerate(articles[:limit], 1):
1075
+ if isinstance(a, dict):
1076
+ title = a.get("title", "Untitled")
1077
+ source = a.get("source", a.get("publisher", ""))
1078
+ url_item = a.get("url", a.get("link", ""))
1079
+ pub_date = a.get("published_at", a.get("date", a.get("publishedAt", "")))
1080
+ if pub_date:
1081
+ pub_date = pub_date[:10] if len(pub_date) >= 10 else pub_date
1082
+ else:
1083
+ title = str(a)
1084
+ source = pub_date = url_item = ""
1085
+ if HAS_RICH:
1086
+ console.print(f" [bold]{idx}.[/bold] {title}")
1087
+ meta_parts = [p for p in [source, pub_date] if p]
1088
+ if meta_parts:
1089
+ console.print(f" [dim]{' · '.join(meta_parts)}[/dim]")
1090
+ else:
1091
+ meta = f" ({source})" if source else ""
1092
+ print(f" {idx}. {title}{meta}")
1093
+ if HAS_RICH:
1094
+ console.print()
1095
+ else:
1096
+ # Empty articles — show helpful config guidance
1097
+ _data_keys = _load_data_keys()
1098
+ if HAS_RICH:
1099
+ console.print()
1100
+ console.print(f" [dim]未找到 '{topic}' 的相关新闻。[/dim]")
1101
+ if not _data_keys.get("finnhub") and not _data_keys.get("newsapi"):
1102
+ console.print(" [dim]配置数据服务 key 可获取更多新闻来源:[/dim]")
1103
+ console.print(" [dim] /apikey set finnhub <key> → https://finnhub.io/register[/dim]")
1104
+ console.print(" [dim] /apikey set newsapi <key> → https://newsapi.org/register[/dim]")
1105
+ console.print()
1106
+ else:
1107
+ articles = await asyncio.get_event_loop().run_in_executor(
1108
+ None, _fetch_public_news_fallback, topic, limit
1109
+ )
1110
+ if isinstance(articles, list) and articles:
1111
+ if HAS_RICH:
1112
+ console.print()
1113
+ console.print(" [dim]新闻 API 不可用,已使用公共 RSS fallback。[/dim]")
1114
+ console.print()
1115
+ for idx, a in enumerate(articles[:limit], 1):
1116
+ title = a.get("title", "Untitled") if isinstance(a, dict) else str(a)
1117
+ source = a.get("source", "") if isinstance(a, dict) else ""
1118
+ pub_date = a.get("published_at", "") if isinstance(a, dict) else ""
1119
+ if pub_date:
1120
+ pub_date = pub_date[:10] if len(pub_date) >= 10 else pub_date
1121
+ if HAS_RICH:
1122
+ console.print(f" [bold]{idx}.[/bold] {title}")
1123
+ meta_parts = [p for p in [source, pub_date] if p]
1124
+ if meta_parts:
1125
+ console.print(f" [dim]{' · '.join(meta_parts)}[/dim]")
1126
+ else:
1127
+ meta = f" ({source})" if source else ""
1128
+ print(f" {idx}. {title}{meta}")
1129
+ if HAS_RICH:
1130
+ console.print()
1131
+ return
1132
+
1133
+ # Backend + all local fallbacks unavailable — show actionable config guide
1134
+ err = result.get("error", "")
1135
+ _data_keys = _load_data_keys()
1136
+ _has_finnhub = bool(_data_keys.get("finnhub"))
1137
+ _has_newsapi = bool(_data_keys.get("newsapi"))
1138
+ if HAS_RICH:
1139
+ console.print()
1140
+ console.print(f" [yellow]⚠ 新闻服务不可用[/yellow]")
1141
+ if not _has_finnhub and not _has_newsapi:
1142
+ console.print(" [dim]配置以下任意一个数据服务 key 即可获取新闻:[/dim]")
1143
+ console.print(" [dim] Finnhub (免费60次/分) → /apikey set finnhub <key> 注册: https://finnhub.io/register[/dim]")
1144
+ console.print(" [dim] NewsAPI (免费100次/天) → /apikey set newsapi <key> 注册: https://newsapi.org/register[/dim]")
1145
+ else:
1146
+ console.print(f" [dim]错误: {err[:120] if err else '获取失败'}[/dim]")
1147
+ console.print(f" [dim]或使用: /web {topic} latest news — 通过 Brave 搜索[/dim]")
1148
+ console.print()
1149
+ else:
1150
+ print(f" News unavailable. Configure: /apikey set finnhub <key>")
1151
+
1152
+ async def cmd_quote(self, args: str):
1153
+ symbols = parse_symbols(args, self.terminal.config.get("watchlist", ["AAPL"]))
1154
+
1155
+ # 优先使用 MarketDataClient(真实实时数据,代理绕过)
1156
+ if _HAS_MDC:
1157
+ mdc = _get_mdc()
1158
+ if HAS_RICH:
1159
+ console.print()
1160
+ for symbol in symbols:
1161
+ if HAS_RICH:
1162
+ with console.status(f"[dim]{symbol}...[/dim]", spinner="dots"):
1163
+ loop = asyncio.get_event_loop()
1164
+ r = await loop.run_in_executor(None, mdc.quote, symbol)
1165
+ else:
1166
+ r = mdc.quote(symbol)
1167
+
1168
+ if r.get("success"):
1169
+ name = r.get("name", symbol)
1170
+ # Supplement Chinese name for A-shares where yfinance returns ASCII
1171
+ if _is_ashare_symbol(symbol) and (not name or name == symbol or name.replace(" ","").isascii()):
1172
+ _cn = _ashare_code_to_name(symbol)
1173
+ if _cn:
1174
+ name = _cn
1175
+ print_quote_result(console=console, has_rich=HAS_RICH, symbol=symbol, quote=r, name=name)
1176
+ else:
1177
+ print_quote_result(console=console, has_rich=HAS_RICH, symbol=symbol, quote=r)
1178
+ if HAS_RICH:
1179
+ console.print()
1180
+ return
1181
+
1182
+ # Fallback:原有 Aria 工具
1183
+ for symbol in symbols:
1184
+ if HAS_RICH:
1185
+ with console.status(f"[dim]Fetching {symbol}...[/dim]", spinner="dots"):
1186
+ result = await execute_aria_tool(self.terminal.api_url, "get_market_data", {
1187
+ "symbol": symbol, "market": "US", "period": "1mo"
1188
+ })
1189
+ else:
1190
+ print(f"Fetching {symbol}...")
1191
+ result = await execute_aria_tool(self.terminal.api_url, "get_market_data", {
1192
+ "symbol": symbol, "market": "US", "period": "1mo"
1193
+ })
1194
+ if not result:
1195
+ _print_error(f"{symbol}: 数据服务不可用(API未运行)", "tool")
1196
+ continue
1197
+ if result.get("success") and result.get("data"):
1198
+ output = format_quote_output(result)
1199
+ console.print(output)
1200
+ else:
1201
+ _print_error(f"Failed: {result.get('error', 'No data')}")
1202
+
1203
+ async def cmd_screen_cn(self, args: str):
1204
+ """A股选股筛选器 (local, akshare)."""
1205
+ params: Dict[str, Any] = {}
1206
+ for tok in args.split():
1207
+ if "=" in tok:
1208
+ k, v = tok.split("=", 1)
1209
+ params[k.strip()] = v.strip()
1210
+ tool_name = "screen_ashare"
1211
+ if tool_name in LOCAL_TOOLS:
1212
+ await self._run_local_tool(tool_name, params, "A股选股筛选")
1213
+ else:
1214
+ await self.terminal.send_message(f"帮我筛选A股股票,条件:{args or '市值>50亿,非ST,流动性好'}")
1215
+
1216
+ async def cmd_limitup(self, args: str):
1217
+ """A股涨停板池. Usage: /limitup [YYYY-MM-DD] [code_filter]"""
1218
+ import re as _re_lu
1219
+ arg = args.strip()
1220
+
1221
+ # Detect if arg looks like a stock code (6 digits) vs a date
1222
+ _is_code = bool(_re_lu.match(r'^[036]\d{5}$', arg))
1223
+ _is_date = bool(_re_lu.match(r'^\d{4}-\d{2}-\d{2}$', arg))
1224
+ _code_filter = arg if _is_code else None
1225
+ _date_arg = arg if _is_date else ""
1226
+ params = {"date": _date_arg} if _date_arg else {}
1227
+
1228
+ tool_name = "get_limit_up_pool"
1229
+ if tool_name in LOCAL_TOOLS:
1230
+ await self._run_local_tool(tool_name, params, "涨停板池")
1231
+ else:
1232
+ # Direct akshare fallback — avoids "A股" keyword triggering market snapshot routing
1233
+ try:
1234
+ import akshare as ak
1235
+ from datetime import date as _dt
1236
+ _date_str = (_date_arg.replace("-", "") if _date_arg
1237
+ else _dt.today().strftime("%Y%m%d"))
1238
+ _df = ak.stock_zt_pool_em(date=_date_str)
1239
+ if _df is not None and not _df.empty:
1240
+ if _code_filter:
1241
+ _col = next((c for c in _df.columns if "代码" in str(c) or c == "code"), None)
1242
+ if _col:
1243
+ _df = _df[_df[_col].astype(str) == _code_filter]
1244
+ _count = len(_df)
1245
+ if HAS_RICH:
1246
+ from rich.table import Table
1247
+ _date_label = _date_arg or _dt.today().isoformat()
1248
+ tbl = Table(title=f"涨停板池 · {_date_label} · {_count}只", show_header=True, header_style="bold")
1249
+ _col_map = {"代码": "代码", "名称": "名称", "涨停统计": "涨停统计",
1250
+ "连续涨停": "连板", "首次封板时间": "首封", "涨停类型": "类型"}
1251
+ _show_cols = [c for c in _df.columns if c in _col_map]
1252
+ for c in _show_cols:
1253
+ tbl.add_column(_col_map.get(c, c), no_wrap=True)
1254
+ for _, row in _df.head(30).iterrows():
1255
+ tbl.add_row(*[str(row[c]) for c in _show_cols])
1256
+ console.print(tbl)
1257
+ else:
1258
+ print(f"涨停板池 {_count}只")
1259
+ for _, row in _df.head(20).iterrows():
1260
+ print(f" {row.get('代码','')} {row.get('名称','')}")
1261
+ return
1262
+ except Exception as _e:
1263
+ pass
1264
+ if HAS_RICH:
1265
+ console.print("[yellow]akshare 暂不可用,涨停板池无法获取[/yellow]")
1266
+ else:
1267
+ print("akshare unavailable, cannot fetch limit-up pool")
1268
+
1269
+ async def cmd_north(self, args: str):
1270
+ """北向资金净流入."""
1271
+ params = {"days": int(args.strip())} if args.strip().isdigit() else {"days": 10}
1272
+ tool_name = "get_northbound_flow"
1273
+ if tool_name in LOCAL_TOOLS:
1274
+ await self._run_local_tool(tool_name, params, "北向资金")
1275
+ else:
1276
+ await self.terminal.send_message("查询最近10天北向资金(沪深港通)净买入情况")