aria-code 4.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
|
@@ -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)}®ion=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天北向资金(沪深港通)净买入情况")
|