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,2509 @@
1
+ """Market data handlers extracted from aria_cli.py.
2
+
3
+ Handles market data prefetching, snapshot rows, and full snapshot analysis.
4
+ Imports market detection helpers from apps.cli.utils.market_detect.
5
+ _HAS_MDC and _get_mdc are resolved via lazy import to avoid circular deps.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import os
10
+ import sys
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from apps.cli.utils.market_detect import (
15
+ _re_sym, _STOCK_PATTERN,
16
+ _CRYPTO_WORDS, _COMPANY_TO_TICKER,
17
+ _FINANCIAL_TERMS_BLOCKLIST,
18
+ _extract_market_symbol, _extract_market_symbols, _extract_symbol_from_history,
19
+ _is_realty_query, _is_market_snapshot_request,
20
+ _format_compact_market_cap, _market_snapshot_trend,
21
+ _has_unresolved_company_mention,
22
+ _detect_market_overview,
23
+ _PRIVATE_COMPANY_PROFILES,
24
+ )
25
+ from apps.cli.market_metadata import enrich_market_quote, market_display_label
26
+
27
+ _PROVIDERS_FILE = Path.home() / ".arthera" / "providers.json"
28
+
29
+
30
+ def _detect_lang(text: str) -> str:
31
+ """Return 'zh' for predominantly Chinese input, 'en' otherwise."""
32
+ if not text:
33
+ return "zh"
34
+ zh_chars = sum(1 for c in text if '一' <= c <= '鿿')
35
+ return "zh" if zh_chars / max(len(text), 1) > 0.15 else "en"
36
+
37
+
38
+ # ── TA session cache (populated during prefetch, read during snapshot) ────────
39
+ _TA_SESSION_CACHE: dict = {}
40
+ _TA_SESSION_CACHE_TTL = 600 # 10 minutes
41
+ _LEVEL_HISTORY_CACHE: dict = {}
42
+ _LEVEL_HISTORY_CACHE_TTL = 300 # 5 minutes
43
+
44
+
45
+ def _fmt_int(value) -> str:
46
+ try:
47
+ return f"{int(float(value)):,}"
48
+ except Exception:
49
+ return "N/A"
50
+
51
+
52
+ def _num_or_none(value):
53
+ try:
54
+ if value in (None, "", "N/A", "-", "nan"):
55
+ return None
56
+ out = float(value)
57
+ return out if out == out else None
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _fmt_money(currency: str, value) -> str:
63
+ num = _num_or_none(value)
64
+ if num is None:
65
+ return "—"
66
+ return f"{currency} {num:,.2f}"
67
+
68
+
69
+ def _clean_provider_chain(providers) -> list[str]:
70
+ generic = {
71
+ "quote",
72
+ "fundamentals",
73
+ "technical",
74
+ "history",
75
+ "market_data_client",
76
+ "market_data_client.quote",
77
+ "market_data_client.fundamentals",
78
+ "market_data_client.technical_indicators",
79
+ }
80
+ out: list[str] = []
81
+ for provider in providers or []:
82
+ p = str(provider or "").strip()
83
+ if not p or p in generic:
84
+ continue
85
+ if p not in out:
86
+ out.append(p)
87
+ return out
88
+
89
+
90
+ def _snapshot_signal(price, change_pct, rsi, macd_hist, ma20, ma60) -> tuple[str, int, float, str]:
91
+ """Return (signal, score, confidence, label) for a compact market snapshot."""
92
+ enough = any(v is not None for v in (rsi, macd_hist, ma20, ma60))
93
+ if not enough:
94
+ return "—", 0, 0.0, "指标不足"
95
+ score = 0
96
+ if ma20 is not None and price is not None:
97
+ score += 1 if price > ma20 else -1
98
+ if ma60 is not None and price is not None:
99
+ score += 1 if price > ma60 else -1
100
+ if macd_hist is not None:
101
+ score += 1 if macd_hist > 0 else -1 if macd_hist < 0 else 0
102
+ if rsi is not None:
103
+ if rsi <= 30:
104
+ score += 2
105
+ elif rsi <= 40:
106
+ score += 1
107
+ elif rsi >= 75:
108
+ score -= 2
109
+ elif rsi >= 65:
110
+ score -= 1
111
+ if change_pct is not None:
112
+ if change_pct >= 2:
113
+ score += 1
114
+ elif change_pct <= -2:
115
+ score -= 1
116
+
117
+ if score >= 4:
118
+ signal, label = "STRONG_BUY", "强势多头"
119
+ elif score >= 2:
120
+ signal, label = "BUY", "偏多"
121
+ elif score <= -4:
122
+ signal, label = "STRONG_SELL", "强势空头"
123
+ elif score <= -2:
124
+ signal, label = "SELL", "偏空"
125
+ elif score == 1:
126
+ signal, label = "HOLD+", "短线偏强"
127
+ elif score == -1:
128
+ signal, label = "HOLD-", "短线偏弱"
129
+ else:
130
+ signal, label = "NEUTRAL", "震荡观察"
131
+ confidence = min(0.82, 0.46 + abs(score) * 0.07)
132
+ return signal, score, confidence, label
133
+
134
+
135
+ def _support_resistance_for_row(currency: str, price, ma20, ma60, bb_lower, bb_upper) -> tuple[str, str]:
136
+ p = _num_or_none(price)
137
+ if p is None:
138
+ return "", ""
139
+ supports = sorted(
140
+ {round(v, 2) for v in (_num_or_none(bb_lower), _num_or_none(ma60), _num_or_none(ma20)) if v is not None and v < p},
141
+ reverse=True,
142
+ )[:3]
143
+ resistances = sorted(
144
+ {round(v, 2) for v in (_num_or_none(ma20), _num_or_none(ma60), _num_or_none(bb_upper)) if v is not None and v > p},
145
+ )[:3]
146
+ support_str = ", ".join(f"{currency} {v:,.2f}" for v in supports)
147
+ resistance_str = ", ".join(f"{currency} {v:,.2f}" for v in resistances)
148
+ return support_str, resistance_str
149
+
150
+
151
+ def _history_records(history_result) -> list[dict]:
152
+ """Return normalized OHLC records from a market_data_client history result."""
153
+ if not isinstance(history_result, dict) or not history_result.get("success"):
154
+ return []
155
+ records = history_result.get("data") or []
156
+ out: list[dict] = []
157
+ for row in records:
158
+ if not isinstance(row, dict):
159
+ continue
160
+ close = _num_or_none(row.get("close") or row.get("Close"))
161
+ high = _num_or_none(row.get("high") or row.get("High") or close)
162
+ low = _num_or_none(row.get("low") or row.get("Low") or close)
163
+ open_ = _num_or_none(row.get("open") or row.get("Open") or close)
164
+ if close is None or high is None or low is None:
165
+ continue
166
+ out.append({
167
+ "date": row.get("date") or row.get("datetime") or row.get("time") or "",
168
+ "open": open_ if open_ is not None else close,
169
+ "high": high,
170
+ "low": low,
171
+ "close": close,
172
+ "volume": _num_or_none(row.get("volume") or row.get("Volume")),
173
+ })
174
+ return out
175
+
176
+
177
+ def _cached_history_records(mdc, symbol: str, days: int, interval: str) -> list[dict]:
178
+ if mdc is None or not hasattr(mdc, "history"):
179
+ return []
180
+ import time as _time
181
+ key = (str(symbol).upper(), int(days), str(interval))
182
+ now = _time.time()
183
+ cached = _LEVEL_HISTORY_CACHE.get(key)
184
+ if cached and now - cached.get("ts", 0) < _LEVEL_HISTORY_CACHE_TTL:
185
+ return cached.get("records", [])[:]
186
+ try:
187
+ records = _history_records(mdc.history(symbol, days=days, interval=interval))
188
+ except Exception:
189
+ records = []
190
+ if records:
191
+ _LEVEL_HISTORY_CACHE[key] = {"ts": now, "records": records}
192
+ return records
193
+
194
+
195
+ def _aggregate_ohlc(records: list[dict], bars: int) -> list[dict]:
196
+ """Aggregate chronological OHLC records into fixed-size bars."""
197
+ if bars <= 1 or len(records) < bars:
198
+ return records[:]
199
+ out: list[dict] = []
200
+ for i in range(0, len(records), bars):
201
+ chunk = records[i:i + bars]
202
+ if len(chunk) < bars:
203
+ continue
204
+ out.append({
205
+ "date": chunk[-1].get("date") or "",
206
+ "open": chunk[0]["open"],
207
+ "high": max(float(r["high"]) for r in chunk),
208
+ "low": min(float(r["low"]) for r in chunk),
209
+ "close": chunk[-1]["close"],
210
+ "volume": sum(float(r.get("volume") or 0) for r in chunk),
211
+ })
212
+ return out
213
+
214
+
215
+ def _rolling_mean_from_records(records: list[dict], n: int) -> float | None:
216
+ if len(records) < n:
217
+ return None
218
+ vals = [_num_or_none(r.get("close")) for r in records[-n:]]
219
+ vals = [v for v in vals if v is not None]
220
+ if len(vals) < n:
221
+ return None
222
+ return sum(vals) / len(vals)
223
+
224
+
225
+ def _bollinger_from_records(records: list[dict], n: int = 20) -> tuple[float | None, float | None]:
226
+ if len(records) < n:
227
+ return None, None
228
+ vals = [_num_or_none(r.get("close")) for r in records[-n:]]
229
+ vals = [v for v in vals if v is not None]
230
+ if len(vals) < n:
231
+ return None, None
232
+ mean = sum(vals) / len(vals)
233
+ if len(vals) < 2:
234
+ return None, None
235
+ variance = sum((v - mean) ** 2 for v in vals) / (len(vals) - 1)
236
+ std = variance ** 0.5
237
+ return mean + 2 * std, mean - 2 * std
238
+
239
+
240
+ def _nearest_levels(
241
+ records: list[dict],
242
+ price,
243
+ *,
244
+ window: int,
245
+ extra_levels=(),
246
+ max_levels: int = 3,
247
+ ) -> tuple[list[float], list[float]]:
248
+ """Find nearest support/resistance using swing points plus dynamic levels."""
249
+ p = _num_or_none(price)
250
+ if p is None:
251
+ return [], []
252
+ highs = [_num_or_none(r.get("high")) for r in records]
253
+ lows = [_num_or_none(r.get("low")) for r in records]
254
+ closes = [_num_or_none(r.get("close")) for r in records]
255
+ usable = [
256
+ (h, l, c)
257
+ for h, l, c in zip(highs, lows, closes)
258
+ if h is not None and l is not None and c is not None
259
+ ]
260
+ if len(usable) < max(5, window * 2 + 1):
261
+ candidates = [_num_or_none(v) for v in extra_levels]
262
+ else:
263
+ highs = [float(h) for h, _l, _c in usable]
264
+ lows = [float(l) for _h, l, _c in usable]
265
+ candidates: list[float | None] = []
266
+ for i in range(window, len(usable) - window):
267
+ hi_slice = highs[i - window:i + window + 1]
268
+ lo_slice = lows[i - window:i + window + 1]
269
+ if highs[i] == max(hi_slice):
270
+ candidates.append(highs[i])
271
+ if lows[i] == min(lo_slice):
272
+ candidates.append(lows[i])
273
+ candidates.extend(_num_or_none(v) for v in extra_levels)
274
+
275
+ support = sorted(
276
+ {round(float(v), 2) for v in candidates if v is not None and float(v) < p},
277
+ reverse=True,
278
+ )[:max_levels]
279
+ resistance = sorted(
280
+ {round(float(v), 2) for v in candidates if v is not None and float(v) > p},
281
+ )[:max_levels]
282
+ return support, resistance
283
+
284
+
285
+ def _format_levels(currency: str, levels: list[float]) -> str:
286
+ return ", ".join(f"{currency} {v:,.2f}" for v in levels) if levels else "—"
287
+
288
+
289
+ def _level_action_line(
290
+ name: str,
291
+ support: list[float],
292
+ resistance: list[float],
293
+ currency: str,
294
+ *,
295
+ english: bool = False,
296
+ ) -> str:
297
+ sup = f"{currency} {support[0]:,.2f}" if support else ""
298
+ res = f"{currency} {resistance[0]:,.2f}" if resistance else ""
299
+ if english:
300
+ if name.startswith("4H"):
301
+ if sup and res:
302
+ return f"Short-term: break {res} to chase strength; lose {sup} to reduce risk."
303
+ return "Short-term: wait for a cleaner nearby level."
304
+ if name.startswith("Daily"):
305
+ if sup and res:
306
+ return f"Swing: hold {sup}; reclaim {res} for daily repair."
307
+ return "Swing: level data is incomplete."
308
+ if sup and res:
309
+ return f"Position: {sup} is the structural line; {res} is the next supply zone."
310
+ return "Position: structural levels are incomplete."
311
+ if name.startswith("4H"):
312
+ if sup and res:
313
+ return f"短线:上破 {res} 转强;跌破 {sup} 降风险。"
314
+ return "短线:近端关键位不足,先等价格走出区间。"
315
+ if name.startswith("日线"):
316
+ if sup and res:
317
+ return f"波段:守住 {sup} 结构未破;站回 {res} 修复。"
318
+ return "波段:日线关键位不足,需结合成交量确认。"
319
+ if sup and res:
320
+ return f"长线:{sup} 是结构防线;{res} 是上方供给/趋势压力。"
321
+ return "长线:结构位不足,暂不做长期突破判断。"
322
+
323
+
324
+ def _timeframe_display_name(name: str, *, english: bool = False) -> str:
325
+ if not english:
326
+ return name
327
+ if name.startswith("4H"):
328
+ return "4H/Short-term"
329
+ if name.startswith("日线"):
330
+ return "Daily/Swing"
331
+ if name.startswith("周线"):
332
+ return "Weekly/Position"
333
+ return name
334
+
335
+
336
+ def _timeframe_source_label(source: str, *, english: bool = False) -> str:
337
+ if english:
338
+ return {
339
+ "1h→4h": "1h aggregated to 4H",
340
+ "near-term daily": "near-term daily fallback",
341
+ "daily swings + MA/BOLL": "daily swings + MA/BOLL",
342
+ "daily→weekly swings": "daily aggregated to weekly",
343
+ "MA/BOLL fallback": "MA/BOLL fallback",
344
+ }.get(source, source)
345
+ return {
346
+ "1h→4h": "1小时线聚合",
347
+ "near-term daily": "近端日线替代",
348
+ "daily swings + MA/BOLL": "日线摆动 + 均线/布林",
349
+ "daily→weekly swings": "日线聚合周线",
350
+ "MA/BOLL fallback": "均线/布林兜底",
351
+ }.get(source, source)
352
+
353
+
354
+ def _append_timeframe_levels(
355
+ lines: list[str],
356
+ timeframe_levels: list[dict],
357
+ currency: str,
358
+ *,
359
+ english: bool = False,
360
+ ) -> None:
361
+ """Append narrow-terminal friendly multi-timeframe levels.
362
+
363
+ A Markdown table with five columns truncates the long "Use" text in 80-col
364
+ terminals. Blocks keep every level visible and let Rich wrap text naturally.
365
+ """
366
+ if not timeframe_levels:
367
+ return
368
+
369
+ lines.append("")
370
+ lines.append(f"**{'Multi-timeframe key levels' if english else '多周期关键位'}**")
371
+ for row in timeframe_levels[:3]:
372
+ raw_name = str(row.get("name") or "—")
373
+ name = _timeframe_display_name(raw_name, english=english)
374
+ source = _timeframe_source_label(str(row.get("source") or ""), english=english)
375
+ horizon = str(row.get("horizon") or "—")
376
+ support = row.get("support") or []
377
+ resistance = row.get("resistance") or []
378
+ action = _level_action_line(name, support, resistance, currency, english=english)
379
+
380
+ if english:
381
+ meta = f"For: {horizon}"
382
+ if source:
383
+ meta += f" · Source: {source}"
384
+ lines.append(f"- **{name}** — {meta}")
385
+ lines.append(f" - Support: {_format_levels(currency, support)}")
386
+ lines.append(f" - Resistance: {_format_levels(currency, resistance)}")
387
+ lines.append(f" - Use: {action}")
388
+ else:
389
+ meta = f"适合:{horizon}"
390
+ if source:
391
+ meta += f" · 来源:{source}"
392
+ lines.append(f"- **{name}** — {meta}")
393
+ lines.append(f" - 支撑:{_format_levels(currency, support)}")
394
+ lines.append(f" - 压力:{_format_levels(currency, resistance)}")
395
+ lines.append(f" - 用法:{action}")
396
+
397
+
398
+ def _build_timeframe_levels(
399
+ mdc,
400
+ symbol: str,
401
+ price,
402
+ currency: str,
403
+ *,
404
+ ma20=None,
405
+ ma60=None,
406
+ bb_lower=None,
407
+ bb_upper=None,
408
+ fallback_supports: list[float] | None = None,
409
+ fallback_resistances: list[float] | None = None,
410
+ ) -> list[dict]:
411
+ """Build 4H/short, daily, and weekly/position support-resistance levels."""
412
+ p = _num_or_none(price)
413
+ if p is None:
414
+ return []
415
+ fallback_supports = fallback_supports or []
416
+ fallback_resistances = fallback_resistances or []
417
+ rows: list[dict] = []
418
+
419
+ daily_records = _cached_history_records(mdc, symbol, 370, "1d")
420
+ intraday_records = _cached_history_records(mdc, symbol, 30, "1h")
421
+
422
+ short_source = "1h→4h" if len(intraday_records) >= 24 else "near-term daily"
423
+ short_records = _aggregate_ohlc(intraday_records, 4) if len(intraday_records) >= 24 else daily_records[-30:]
424
+ if short_records:
425
+ short_ma20 = _rolling_mean_from_records(short_records, 20)
426
+ short_sup, short_res = _nearest_levels(
427
+ short_records,
428
+ p,
429
+ window=2 if len(short_records) < 60 else 3,
430
+ extra_levels=(short_ma20, *fallback_supports, *fallback_resistances),
431
+ )
432
+ rows.append({
433
+ "name": "4H/短线",
434
+ "horizon": "1-5 日",
435
+ "source": short_source,
436
+ "support": short_sup,
437
+ "resistance": short_res,
438
+ })
439
+
440
+ if daily_records:
441
+ day_bbu, day_bbl = _bollinger_from_records(daily_records, 20)
442
+ day_sup, day_res = _nearest_levels(
443
+ daily_records[-180:],
444
+ p,
445
+ window=5,
446
+ extra_levels=(ma20, ma60, bb_lower, bb_upper, day_bbl, day_bbu),
447
+ )
448
+ rows.append({
449
+ "name": "日线/波段",
450
+ "horizon": "1-8 周",
451
+ "source": "daily swings + MA/BOLL",
452
+ "support": day_sup,
453
+ "resistance": day_res,
454
+ })
455
+
456
+ weekly_records = _aggregate_ohlc(daily_records[-260:], 5)
457
+ weekly_ma20 = _rolling_mean_from_records(weekly_records, 20)
458
+ weekly_ma40 = _rolling_mean_from_records(weekly_records, 40)
459
+ long_high = max((float(r["high"]) for r in daily_records[-260:] if r.get("high") is not None), default=None)
460
+ long_low = min((float(r["low"]) for r in daily_records[-260:] if r.get("low") is not None), default=None)
461
+ long_sup, long_res = _nearest_levels(
462
+ weekly_records,
463
+ p,
464
+ window=3,
465
+ extra_levels=(weekly_ma20, weekly_ma40, long_high, long_low),
466
+ )
467
+ rows.append({
468
+ "name": "周线/长线",
469
+ "horizon": "2-12 月",
470
+ "source": "daily→weekly swings",
471
+ "support": long_sup,
472
+ "resistance": long_res,
473
+ })
474
+
475
+ if not rows and (fallback_supports or fallback_resistances):
476
+ rows.append({
477
+ "name": "近端关键位",
478
+ "horizon": "快照",
479
+ "source": "MA/BOLL fallback",
480
+ "support": fallback_supports[:3],
481
+ "resistance": fallback_resistances[:3],
482
+ })
483
+ return rows
484
+
485
+
486
+ def _is_market_share_request(message: str) -> bool:
487
+ text = (message or "").lower()
488
+ return any(k in text for k in (
489
+ "市场份额", "市占率", "份额", "竞争格局", "market share", "share of market",
490
+ ))
491
+
492
+
493
+ def _market_share_note(symbols: list[str], *, english: bool = False) -> list[str]:
494
+ if english:
495
+ return [
496
+ "",
497
+ "Market Share Follow-up",
498
+ "- The table above covers stock price and technical trend only.",
499
+ "- Market share requires business-line research, for example: iPhone vs Android, search, digital ads, cloud, browser, payments, or devices.",
500
+ "- Run `" + " ".join(["/research", *symbols[:2]]) + "` or `/web <company> market share latest` to fetch source-backed share data.",
501
+ ]
502
+ return [
503
+ "",
504
+ "市场份额后续",
505
+ "- 上表只覆盖股价走势、技术指标和市值,不等同于业务市场份额。",
506
+ "- 市场份额需要按业务线拆开研究,例如:手机、搜索、数字广告、云服务、浏览器、支付、硬件生态。",
507
+ "- 可继续运行 `" + " ".join(["/research", *symbols[:2]]) + "`,或 `/web <公司> 市场份额 最新` 获取带来源的份额数据。",
508
+ ]
509
+
510
+
511
+ _DATA_KEY_MAP = {
512
+ "finnhub": "FINNHUB_API_KEY",
513
+ "alphavantage": "ALPHAVANTAGE_API_KEY",
514
+ "polygon": "POLYGON_API_KEY",
515
+ }
516
+
517
+
518
+ def _get_provider_key(provider: str) -> str:
519
+ """Return configured API key for a provider (env var takes priority over providers.json)."""
520
+ env_var = _DATA_KEY_MAP.get(provider.lower(), "")
521
+ if env_var:
522
+ val = os.getenv(env_var, "")
523
+ if val:
524
+ return val
525
+ try:
526
+ if _PROVIDERS_FILE.exists():
527
+ raw = json.loads(_PROVIDERS_FILE.read_text(encoding="utf-8"))
528
+ for section in ("llm", "data"):
529
+ entry = raw.get(section, {}).get(provider.lower(), {})
530
+ if entry.get("api_key"):
531
+ return entry["api_key"]
532
+ except Exception:
533
+ pass
534
+ return ""
535
+
536
+
537
+ # Lazy MDC accessor (mirrors the pattern in market_tools.py)
538
+ def _get_mdc_lazy():
539
+ aria_cli = sys.modules.get("aria_cli")
540
+ injected = getattr(aria_cli, "_get_mdc", None) if aria_cli else None
541
+ if callable(injected):
542
+ try:
543
+ return injected()
544
+ except Exception:
545
+ pass
546
+ try:
547
+ from market_data_client import get_mdc as _gm
548
+ return _gm()
549
+ except Exception:
550
+ return None
551
+
552
+ def _has_mdc_lazy() -> bool:
553
+ aria_cli = sys.modules.get("aria_cli")
554
+ if aria_cli is not None and hasattr(aria_cli, "_HAS_MDC"):
555
+ return bool(getattr(aria_cli, "_HAS_MDC"))
556
+ try:
557
+ import market_data_client # noqa
558
+ return True
559
+ except ImportError:
560
+ return False
561
+
562
+
563
+ def _try_prefetch_market_data(message: str, history: list = None) -> str:
564
+ """
565
+ Pre-fetch real market data and inject it into the system prompt so local
566
+ models always answer with real numbers instead of hallucinating.
567
+
568
+ For technical-analysis queries (support/resistance/RSI/MACD) also fetches
569
+ technical indicators and computes key price levels from the data.
570
+
571
+ 跟进问题支持:当前消息无标的但含市场关键词时,从会话历史继承最近标的
572
+ (如上一轮问"寒武纪趋势",这一轮问"现在的股票和趋势呢")。
573
+
574
+ Returns "" if no market query detected or fetch fails.
575
+ """
576
+ # Real-estate queries must not prefetch stock market data
577
+ if _is_realty_query(message):
578
+ return ""
579
+
580
+ # Trigger for any market / analysis query
581
+ _market_kw = (
582
+ "股票","股价","价格","涨跌","市值","行情","市场","现在多少","现价","今天价格",
583
+ "分析","走势","技术面","基本面","估值","涨跌幅",
584
+ "支撑","阻力","支撑位","阻力位","技术指标","技术分析",
585
+ "stock","price","quote","analyze","analysis","crypto",
586
+ "btc","eth","比特币","以太坊","rsi","macd","bollinger",
587
+ )
588
+ msg_low = message.lower()
589
+ if not any(k in msg_low for k in _market_kw):
590
+ return ""
591
+
592
+ # Detect if this is a technical analysis request
593
+ _tech_kw = ("技术面","技术分析","技术指标","支撑","阻力","支撑位","阻力位",
594
+ "rsi","macd","bollinger","均线","走势","趋势","technical")
595
+ _is_tech_query = any(k in msg_low for k in _tech_kw)
596
+
597
+ msg_for_lookup = message.lower() # case-insensitive company name matching
598
+ _all_syms: list = []
599
+ _seen_syms: set = set()
600
+
601
+ # 1. Known Chinese company / index name → ticker (longest match first, find ALL)
602
+ for cn, tick in sorted(_COMPANY_TO_TICKER.items(), key=lambda x: -len(x[0])):
603
+ if cn.lower() in msg_for_lookup and tick not in _seen_syms:
604
+ _all_syms.append(tick)
605
+ _seen_syms.add(tick)
606
+
607
+ # 2. Crypto names
608
+ for cn, tick in _CRYPTO_WORDS.items():
609
+ if cn.lower() in msg_for_lookup and tick not in _seen_syms:
610
+ _all_syms.append(tick)
611
+ _seen_syms.add(tick)
612
+
613
+ # 3. Uppercase ticker patterns — collect all matches
614
+ for _tm in _re_sym.finditer(r'\b([A-Z]{2,5}(?:\.(?:HK|SH|SZ))?)\b', message):
615
+ tick = _tm.group(1)
616
+ if tick not in _FINANCIAL_TERMS_BLOCKLIST and tick not in _seen_syms:
617
+ _all_syms.append(tick)
618
+ _seen_syms.add(tick)
619
+
620
+ # 4. History fallback when no symbols found
621
+ if not _all_syms and history:
622
+ _hs = _extract_symbol_from_history(history)
623
+ if _hs:
624
+ _all_syms = [_hs]
625
+
626
+ if not _all_syms:
627
+ return ""
628
+
629
+ symbol = _all_syms[0] # primary symbol drives tech-analysis fetch
630
+ _extra_syms = _all_syms[1:3] # up to 2 additional symbols
631
+
632
+ if not _has_mdc_lazy():
633
+ return (
634
+ f"\n## 实时行情状态\n"
635
+ f"- 标的:{symbol}\n"
636
+ f"- 状态:本地 market_data_client 未加载,无法获取实时行情。\n"
637
+ f"- 输出要求:明确说明数据不可用,并建议用户执行 `/quote {symbol}`;"
638
+ "不要输出示例价格、占位符或技术指标。\n"
639
+ )
640
+
641
+ try:
642
+ mdc = _get_mdc_lazy()
643
+ r = mdc.quote(symbol)
644
+ if not r.get("success"):
645
+ return (
646
+ f"\n## 实时行情状态\n"
647
+ f"- 标的:{symbol}\n"
648
+ f"- 状态:当前数据服务无法获取该标的的实时行情。\n"
649
+ f"- 可用操作:运行 `/quote {symbol}` 重试。\n"
650
+ f"- 输出要求:不要输出示例价格、占位符、RSI、MACD 或支撑阻力位。\n"
651
+ )
652
+ r = enrich_market_quote(symbol, r)
653
+ price = r.get("price", "N/A")
654
+ chg = r.get("change_pct", 0)
655
+ name = r.get("name", symbol)
656
+ currency = r.get("currency", "USD")
657
+ high = r.get("high", "N/A")
658
+ low = r.get("low", "N/A")
659
+ vol = r.get("volume", "N/A")
660
+ mktcap = r.get("market_cap")
661
+ cap_str = ""
662
+ if mktcap and mktcap == mktcap: # excludes NaN
663
+ if mktcap >= 1e12:
664
+ cap_str = f"{currency} {mktcap/1e12:.2f}T"
665
+ elif mktcap >= 1e9:
666
+ cap_str = f"{currency} {mktcap/1e9:.1f}B"
667
+ sign = "+" if chg >= 0 else ""
668
+ provider = r.get("provider", "API")
669
+ display_label = market_display_label(symbol, r)
670
+ exchange = r.get("exchange")
671
+
672
+ block = (
673
+ f"\n## 📊 {display_label} 实时行情(来源:{provider})\n"
674
+ f"- **交易代码**:{symbol}" + (f"({exchange})\n" if exchange else "\n") +
675
+ f"- **名称**:{name}\n"
676
+ f"- **最新价**:{currency} {price}\n"
677
+ f"- **涨跌幅**:{sign}{chg:.2f}%\n"
678
+ f"- **今日高/低**:{high} / {low}\n"
679
+ f"- **成交量**:{vol}\n"
680
+ + (f"- **市值**:{cap_str}\n" if cap_str else "")
681
+ )
682
+
683
+ # For technical analysis queries: fetch indicators and compute support/resistance
684
+ if _is_tech_query:
685
+ try:
686
+ import time as _time_ta
687
+ _raw_ti = mdc.technical_indicators(symbol, days=120)
688
+ if isinstance(_raw_ti, dict) and _raw_ti.get("success"):
689
+ _TA_SESSION_CACHE[symbol] = {"data": _raw_ti, "ts": _time_ta.time()}
690
+ ti = _raw_ti
691
+ else:
692
+ # Fall back to session cache
693
+ _cached_ta = _TA_SESSION_CACHE.get(symbol)
694
+ ti = (_cached_ta["data"] if _cached_ta and
695
+ (_time_ta.time() - _cached_ta["ts"]) < _TA_SESSION_CACHE_TTL
696
+ else {})
697
+ if ti.get("success"):
698
+ rsi = ti.get("rsi")
699
+ macd = ti.get("macd")
700
+ msig = ti.get("macd_signal")
701
+ mhist = ti.get("macd_hist")
702
+ bbu = ti.get("bb_upper")
703
+ bbm = ti.get("bb_mid")
704
+ bbl = ti.get("bb_lower")
705
+ ma20 = ti.get("ma20")
706
+ ma60 = ti.get("ma60")
707
+ ma5 = ti.get("ma5")
708
+
709
+ # Derive support / resistance from MAs and Bollinger Bands
710
+ supports = sorted([v for v in [ma20, ma60, bbl] if v], reverse=False)
711
+ resistances = sorted([v for v in [bbu, bbm] if v], reverse=False)
712
+ if isinstance(price, (int, float)):
713
+ # Primary support = nearest MA below current price
714
+ supports = [f"{currency} {v:.2f}" for v in supports if v < price]
715
+ resistances = [f"{currency} {v:.2f}" for v in resistances if v > price]
716
+ else:
717
+ supports = [f"{currency} {v:.2f}" for v in supports]
718
+ resistances = [f"{currency} {v:.2f}" for v in resistances]
719
+
720
+ # Pre-compute signal labels so the model doesn't need to interpret
721
+ rsi_str = f"{rsi:.1f}" if rsi is not None else "N/A"
722
+ if rsi is not None:
723
+ if rsi >= 70:
724
+ rsi_signal = f"⚠️ 超买 (RSI={rsi:.1f} ≥ 70,回调风险)"
725
+ elif rsi <= 30:
726
+ rsi_signal = f"⚠️ 超卖 (RSI={rsi:.1f} ≤ 30,反弹机会)"
727
+ else:
728
+ rsi_signal = f"中性 (RSI={rsi:.1f},30-70区间,无超买超卖)"
729
+ else:
730
+ rsi_signal = "N/A"
731
+
732
+ # Show MACD histogram prominently (not the MACD line)
733
+ if mhist is not None:
734
+ macd_hist_str = f"{mhist:.4f}"
735
+ macd_signal = "金叉/多头" if mhist > 0 else "死叉/空头"
736
+ macd_label = f"MACD hist={macd_hist_str},信号:{macd_signal}"
737
+ else:
738
+ macd_hist_str = "N/A"
739
+ macd_signal = "N/A"
740
+ macd_label = "N/A"
741
+
742
+ block += (
743
+ f"\n## 📈 技术分析数据(基于120日历史,已预计算信号)\n\n"
744
+ f"### 技术指标与信号\n"
745
+ f"| 指标 | 数值 | 信号判断 |\n"
746
+ f"| --- | --- | --- |\n"
747
+ f"| RSI(14) | {rsi_str} | {rsi_signal} |\n"
748
+ f"| MACD hist(12,26,9) | {macd_hist_str} | {macd_signal}(hist{'>'if mhist and mhist>0 else '<'}0) |\n"
749
+ + (f"| MA5 | {currency} {ma5:.2f} | 短期均线 |\n" if ma5 else "")
750
+ + (f"| MA20 | {currency} {ma20:.2f} | 中期支撑/压力 |\n" if ma20 else "")
751
+ + (f"| MA60 | {currency} {ma60:.2f} | 长期支撑/压力 |\n" if ma60 else "")
752
+ + (f"| BB Upper | {currency} {bbu:.2f} | 上轨阻力 |\n" if bbu else "")
753
+ + (f"| BB Lower | {currency} {bbl:.2f} | 下轨支撑 |\n" if bbl else "")
754
+ + f"\n### 关键价位(直接引用这些数字)\n"
755
+ + f"- **支撑位**:{', '.join(supports) if supports else '无(当前价已在主要支撑下方)'}\n"
756
+ + f"- **阻力位**:{', '.join(resistances) if resistances else '无(当前价已突破布林上轨)'}\n"
757
+ + f"\n### 技术信号汇总\n"
758
+ + f"- RSI:{rsi_signal}\n"
759
+ + f"- MACD:{macd_label}\n"
760
+ )
761
+ except Exception:
762
+ pass # Technical fetch failure is non-fatal; basic quote still injected
763
+
764
+ # Fetch additional symbols detected in the same message
765
+ for _xs in _extra_syms:
766
+ try:
767
+ _xr = mdc.quote(_xs)
768
+ if _xr.get("success"):
769
+ _xr = enrich_market_quote(_xs, _xr)
770
+ _xp = _xr.get("price", "N/A")
771
+ _xchg = _xr.get("change_pct", 0)
772
+ _xn = _xr.get("name", _xs)
773
+ _xc = _xr.get("currency", "USD")
774
+ _xsign = "+" if _xchg >= 0 else ""
775
+ _x_label = market_display_label(_xs, _xr)
776
+ _x_exchange = _xr.get("exchange")
777
+ block += (
778
+ f"\n## 📊 {_x_label} 实时行情(来源:{_xr.get('provider', 'API')})\n"
779
+ f"- **交易代码**:{_xs}" + (f"({_x_exchange})\n" if _x_exchange else "\n") +
780
+ f"- **名称**:{_xn}\n"
781
+ f"- **最新价**:{_xc} {_xp}\n"
782
+ f"- **涨跌幅**:{_xsign}{_xchg:.2f}%\n"
783
+ f"- **今日高/低**:{_xr.get('high', 'N/A')} / {_xr.get('low', 'N/A')}\n"
784
+ f"- **成交量**:{_xr.get('volume', 'N/A')}\n"
785
+ )
786
+ except Exception:
787
+ pass # additional symbol failure is non-fatal
788
+
789
+ block += f"\n*⚠️ 以上均为真实市场数据。请严格基于这些数字作答,不要修改或编造任何价格/指标数值。货币单位:{currency}。*\n"
790
+ return block
791
+
792
+ except Exception:
793
+ return ""
794
+
795
+
796
+ def _fetch_snapshot_row_for_symbol(symbol: str, mdc) -> dict:
797
+ quote = {}
798
+ fundamentals = {}
799
+ technical = {}
800
+ warnings: list[str] = []
801
+ errors: list[str] = []
802
+ quality: dict = {}
803
+ stale = False
804
+ try:
805
+ from packages.aria_services.data import DataService
806
+ service = DataService(market_client=mdc, router=False)
807
+ quote_result = service.quote(symbol)
808
+ fund_result = service.fundamentals(symbol)
809
+ tech_result = service.technical_indicators(symbol, days=120)
810
+ quote = quote_result.data or {}
811
+ fundamentals = fund_result.data or {}
812
+ technical = tech_result.data or {}
813
+ warnings.extend(quote_result.warnings + fund_result.warnings + tech_result.warnings)
814
+ errors.extend(quote_result.errors + fund_result.errors + tech_result.errors)
815
+ provider_chain = list(dict.fromkeys(
816
+ str(p) for p in (
817
+ quote_result.provider_chain + fund_result.provider_chain + tech_result.provider_chain
818
+ ) if p
819
+ ))
820
+ missing_fields = list(dict.fromkeys(
821
+ quote_result.missing_fields + fund_result.missing_fields + tech_result.missing_fields
822
+ ))
823
+ stale = bool(quote_result.stale or tech_result.stale)
824
+ quality = {
825
+ "status": "partial" if missing_fields else "ok",
826
+ "stale": stale,
827
+ "providers": provider_chain,
828
+ "missing_fields": missing_fields,
829
+ "warnings": warnings[:5],
830
+ "errors": errors[:5],
831
+ }
832
+ except Exception as exc:
833
+ quote = {"success": False, "error": str(exc)}
834
+ warnings.append(f"data_service: {exc}")
835
+ provider_chain = []
836
+ missing_fields = ["price", "market_cap", "technical"]
837
+ quality = {
838
+ "status": "unavailable",
839
+ "stale": False,
840
+ "providers": [],
841
+ "missing_fields": missing_fields,
842
+ "warnings": warnings[:5],
843
+ "errors": [str(exc)],
844
+ }
845
+
846
+ quote = enrich_market_quote(symbol, quote)
847
+ currency = quote.get("currency") or fundamentals.get("currency") or "USD"
848
+ market_cap = (
849
+ quote.get("market_cap")
850
+ or fundamentals.get("market_cap")
851
+ or fundamentals.get("total_mv")
852
+ )
853
+ if not provider_chain:
854
+ provider_chain = []
855
+ for source in (quote, fundamentals, technical):
856
+ chain = source.get("provider_chain")
857
+ if isinstance(chain, list):
858
+ provider_chain.extend(chain)
859
+ elif source.get("provider"):
860
+ provider_chain.append(source.get("provider"))
861
+ elif source.get("source"):
862
+ provider_chain.append(source.get("source"))
863
+ provider_chain = _clean_provider_chain(provider_chain)
864
+ else:
865
+ provider_chain = _clean_provider_chain(provider_chain)
866
+ if not missing_fields:
867
+ missing_fields = []
868
+ # ── yfinance fallback when price is 0 or missing ────────────────────────
869
+ _price_val = quote.get("price")
870
+ _price_bad = not quote.get("success") or _price_val in (None, "", 0) or float(_price_val or 0) == 0
871
+ if _price_bad:
872
+ try:
873
+ import yfinance as _yf_snap
874
+ _sym_yf = symbol.upper()
875
+ _t = _yf_snap.Ticker(_sym_yf)
876
+ _fi = _t.fast_info
877
+ _yf_price = getattr(_fi, "last_price", None) or getattr(_fi, "previous_close", None)
878
+ if _yf_price and float(_yf_price) > 0:
879
+ _yf_prev = getattr(_fi, "previous_close", _yf_price)
880
+ _yf_chg = (float(_yf_price) - float(_yf_prev)) / float(_yf_prev) * 100 if _yf_prev else 0
881
+ _yf_info = {}
882
+ try:
883
+ _yf_info = _t.info or {}
884
+ except Exception:
885
+ pass
886
+ quote = {
887
+ "success": True,
888
+ "symbol": symbol,
889
+ "name": _yf_info.get("shortName") or _yf_info.get("longName") or symbol,
890
+ "price": round(float(_yf_price), 2),
891
+ "change_pct": round(_yf_chg, 2),
892
+ "currency": _yf_info.get("currency") or "USD",
893
+ "market_cap": _yf_info.get("marketCap") or 0,
894
+ "provider": "yfinance",
895
+ "provider_chain": ["yfinance"],
896
+ }
897
+ if not provider_chain or all("edgar" in p.lower() for p in provider_chain):
898
+ provider_chain = ["yfinance"]
899
+ _price_bad = False
900
+ except Exception:
901
+ pass
902
+ _price_val = quote.get("price")
903
+ if _price_bad or _price_val in (None, "", 0):
904
+ missing_fields.append("price")
905
+ if market_cap in (None, "", 0):
906
+ market_cap = quote.get("market_cap") or market_cap
907
+ if market_cap in (None, "", 0):
908
+ missing_fields.append("market_cap")
909
+ if not technical.get("success"):
910
+ missing_fields.append("technical")
911
+
912
+ price_num = _num_or_none(quote.get("price"))
913
+ chg_num = _num_or_none(quote.get("change_pct"))
914
+ rsi = _num_or_none(technical.get("rsi"))
915
+ macd_hist = _num_or_none(technical.get("macd_hist"))
916
+ ma20 = _num_or_none(technical.get("ma20"))
917
+ ma60 = _num_or_none(technical.get("ma60"))
918
+ bb_upper = _num_or_none(technical.get("bb_upper"))
919
+ bb_lower = _num_or_none(technical.get("bb_lower"))
920
+ support_str, resistance_str = _support_resistance_for_row(
921
+ currency, price_num, ma20, ma60, bb_lower, bb_upper
922
+ )
923
+ signal, signal_score, signal_confidence, signal_label = _snapshot_signal(
924
+ price_num, chg_num, rsi, macd_hist, ma20, ma60
925
+ )
926
+ technical_available = any(v is not None for v in (rsi, macd_hist, ma20, ma60, bb_upper, bb_lower))
927
+ missing_fields = [
928
+ field for field in list(dict.fromkeys(missing_fields))
929
+ if not (
930
+ (field == "technical" and technical_available)
931
+ or (field == "macd" and macd_hist is not None)
932
+ )
933
+ ]
934
+
935
+ return {
936
+ "symbol": symbol,
937
+ "name": quote.get("name") or fundamentals.get("name") or symbol,
938
+ "success": bool(quote.get("success")),
939
+ "price": price_num if price_num is not None else quote.get("price"),
940
+ "change_pct": chg_num if chg_num is not None else quote.get("change_pct"),
941
+ "currency": currency,
942
+ "market_cap": market_cap,
943
+ "high": quote.get("high"),
944
+ "low": quote.get("low"),
945
+ "volume": quote.get("volume"),
946
+ "rsi": rsi,
947
+ "macd_hist": macd_hist,
948
+ "ma20": ma20,
949
+ "ma60": ma60,
950
+ "bb_upper": bb_upper,
951
+ "bb_lower": bb_lower,
952
+ "support": support_str,
953
+ "resistance": resistance_str,
954
+ "technical_available": technical_available,
955
+ "technical_provider": technical.get("provider") or technical.get("source") or "",
956
+ "signal": signal,
957
+ "signal_score": signal_score,
958
+ "signal_confidence": signal_confidence,
959
+ "signal_label": signal_label,
960
+ "trend": _market_snapshot_trend(
961
+ quote.get("price"),
962
+ quote.get("high"),
963
+ quote.get("low"),
964
+ quote.get("change_pct"),
965
+ ),
966
+ "provider_chain": provider_chain,
967
+ "missing_fields": missing_fields,
968
+ "error": quote.get("error") or "",
969
+ "warnings": warnings,
970
+ "errors": errors,
971
+ "quality": quality,
972
+ "stale": stale,
973
+ }
974
+
975
+
976
+ def _try_handle_multi_market_snapshot(message: str, symbols: list[str]) -> dict:
977
+ if len(symbols) < 2:
978
+ return {"success": False, "error": "not_multi_symbol"}
979
+ _lang = _detect_lang(message)
980
+ _en = _lang == "en"
981
+ if not _has_mdc_lazy():
982
+ return {
983
+ "success": True,
984
+ "response": (
985
+ ("Market Snapshot\n\nLocal market data client is unavailable.\n\n"
986
+ f"Run `/quote {' '.join(symbols)}` to retry.")
987
+ if _en else
988
+ ("市场快照\n\n当前本地行情客户端未加载,无法获取多标的实时行情。\n\n"
989
+ f"可运行 `/quote {' '.join(symbols)}` 重试。")
990
+ ),
991
+ "tools_used": ["market_snapshot"],
992
+ "analysis_complete": True,
993
+ }
994
+ mdc = _get_mdc_lazy()
995
+ rows = [_fetch_snapshot_row_for_symbol(symbol, mdc) for symbol in symbols]
996
+ now = datetime.now().strftime("%Y-%m-%d")
997
+ provider_chain = list(dict.fromkeys(
998
+ provider for row in rows for provider in row.get("provider_chain", [])
999
+ ))
1000
+ provider_chain = _clean_provider_chain(provider_chain)
1001
+ missing = sorted(set(
1002
+ f"{row['symbol']}:{field}"
1003
+ for row in rows for field in row.get("missing_fields", [])
1004
+ ))
1005
+ stale_symbols = [row["symbol"] for row in rows if row.get("stale")]
1006
+ warnings = [w for row in rows for w in (row.get("warnings") or [])]
1007
+ errors = [e for row in rows for e in (row.get("errors") or [])]
1008
+ ranked = sorted(
1009
+ rows,
1010
+ key=lambda row: (
1011
+ int(row.get("signal_score") or 0),
1012
+ _num_or_none(row.get("change_pct")) or 0,
1013
+ ),
1014
+ reverse=True,
1015
+ )
1016
+ strongest = ranked[0] if ranked else {}
1017
+ weakest = ranked[-1] if ranked else {}
1018
+
1019
+ table = [
1020
+ ("Market Snapshot" if _en else "市场快照") + " · " + now + (" · Multi-symbol comparison" if _en else " · 多标的对比"),
1021
+ "",
1022
+ ]
1023
+ if len(ranked) >= 2:
1024
+ if _en:
1025
+ table.append(
1026
+ f"**Takeaway**: `{strongest.get('symbol')}` is currently stronger than "
1027
+ f"`{weakest.get('symbol')}` by the quantitative snapshot score."
1028
+ )
1029
+ else:
1030
+ table.append(
1031
+ f"**对比结论**:按量化快照分数看,`{strongest.get('symbol')}` "
1032
+ f"当前相对强于 `{weakest.get('symbol')}`。若技术指标缺失,结论仅基于价格/市值快照。"
1033
+ )
1034
+ table.append("")
1035
+
1036
+ if _en:
1037
+ table.extend([
1038
+ "| Symbol | Company | Price | Change | Market Cap | RSI | MACD hist | Signal |",
1039
+ "|---|---|---:|---:|---:|---:|---:|---|",
1040
+ ])
1041
+ else:
1042
+ table.extend([
1043
+ "| 代码 | 公司 | 最新价 | 涨跌幅 | 市值 | RSI | MACD hist | 信号 |",
1044
+ "|---|---|---:|---:|---:|---:|---:|---|",
1045
+ ])
1046
+ for row in rows:
1047
+ currency = row.get("currency") or "USD"
1048
+ if row.get("success") and row.get("price") not in (None, ""):
1049
+ price = _fmt_money(currency, row.get("price"))
1050
+ change = _num_or_none(row.get("change_pct"))
1051
+ change_text = f"{change:+.2f}%" if change is not None else "—"
1052
+ else:
1053
+ price = "—"
1054
+ change_text = "—"
1055
+ rsi_text = f"{row['rsi']:.1f}" if row.get("rsi") is not None else "—"
1056
+ macd_text = f"{row['macd_hist']:.4f}" if row.get("macd_hist") is not None else "—"
1057
+ sig = row.get("signal") or "—"
1058
+ sig_label = row.get("signal_label") or ""
1059
+ sig_text = f"{sig} / {sig_label}" if sig_label and sig != "—" else sig
1060
+ table.append(
1061
+ f"| {row['symbol']} | {row.get('name') or row['symbol']} | {price} | "
1062
+ f"{change_text} | {_format_compact_market_cap(row.get('market_cap'), currency)} | "
1063
+ f"{rsi_text} | {macd_text} | {sig_text} |"
1064
+ )
1065
+
1066
+ table.append("")
1067
+ table.append("Data" if _en else "数据")
1068
+ table.append(f"- {'sources' if _en else '来源'}: {', '.join(provider_chain) if provider_chain else 'unavailable'}")
1069
+ table.append(f"- stale: {', '.join(stale_symbols) if stale_symbols else 'none'}")
1070
+ if missing:
1071
+ table.append(f"- missing: {', '.join(missing)}")
1072
+ else:
1073
+ table.append("- missing: none")
1074
+ technical_ok = [row["symbol"] for row in rows if row.get("technical_available")]
1075
+ technical_missing = [row["symbol"] for row in rows if not row.get("technical_available")]
1076
+ if _en:
1077
+ table.append(
1078
+ "- technical: "
1079
+ + (f"available for {', '.join(technical_ok)}" if technical_ok else "unavailable")
1080
+ + (f"; unavailable for {', '.join(technical_missing)}" if technical_missing else "")
1081
+ )
1082
+ else:
1083
+ table.append(
1084
+ "- 技术指标: "
1085
+ + (f"{', '.join(technical_ok)} 可用" if technical_ok else "暂不可用")
1086
+ + (f";{', '.join(technical_missing)} 暂缺" if technical_missing else "")
1087
+ )
1088
+ if warnings:
1089
+ table.append(f"- warnings: {'; '.join(str(w) for w in warnings[:3])}")
1090
+ if errors:
1091
+ table.append(f"- errors: {'; '.join(str(e) for e in errors[:3])}")
1092
+ if _is_market_share_request(message):
1093
+ table.extend(_market_share_note(symbols, english=_en))
1094
+
1095
+ table.append("")
1096
+ table.append("Analysis" if _en else "逐项分析")
1097
+ for row in rows:
1098
+ currency = row.get("currency") or "USD"
1099
+ price_text = _fmt_money(currency, row.get("price"))
1100
+ change = _num_or_none(row.get("change_pct"))
1101
+ change_text = f"{change:+.2f}%" if change is not None else "—"
1102
+ vol = _fmt_int(row.get("volume")) if row.get("volume") else ""
1103
+ sig = row.get("signal") or "—"
1104
+ sig_label = row.get("signal_label") or "指标不足"
1105
+ score = int(row.get("signal_score") or 0)
1106
+ conf = float(row.get("signal_confidence") or 0)
1107
+ table.append("")
1108
+ if _en:
1109
+ table.append(f"### {row.get('name') or row['symbol']} `{row['symbol']}`")
1110
+ table.append(f"- Price: {price_text}, change {change_text}; trend: {row.get('trend') or '—'}.")
1111
+ if row.get("technical_available"):
1112
+ table.append(
1113
+ f"- Technicals: RSI {row.get('rsi') if row.get('rsi') is not None else '—'}, "
1114
+ f"MACD hist {row.get('macd_hist') if row.get('macd_hist') is not None else '—'}; "
1115
+ f"signal `{sig}` ({sig_label}), score {score:+d}, confidence {conf:.0%}."
1116
+ )
1117
+ if row.get("support") or row.get("resistance"):
1118
+ table.append(f"- Levels: support {row.get('support') or '—'}; resistance {row.get('resistance') or '—'}.")
1119
+ else:
1120
+ table.append("- Technicals: unavailable from current data providers; do not infer RSI/MACD.")
1121
+ if vol:
1122
+ table.append(f"- Volume: {vol}.")
1123
+ else:
1124
+ table.append(f"### {row.get('name') or row['symbol']} `{row['symbol']}`")
1125
+ table.append(f"- 价格:{price_text},涨跌幅 {change_text};趋势:{row.get('trend') or '—'}。")
1126
+ if row.get("technical_available"):
1127
+ table.append(
1128
+ f"- 技术:RSI {row.get('rsi') if row.get('rsi') is not None else '—'},"
1129
+ f"MACD hist {row.get('macd_hist') if row.get('macd_hist') is not None else '—'};"
1130
+ f"信号 `{sig}`({sig_label}),量化分 {score:+d},置信度 {conf:.0%}。"
1131
+ )
1132
+ if row.get("support") or row.get("resistance"):
1133
+ table.append(f"- 关键位:支撑 {row.get('support') or '—'};阻力 {row.get('resistance') or '—'}。")
1134
+ else:
1135
+ table.append("- 技术:当前数据源未返回 RSI/MACD/均线,不据此编造技术指标。")
1136
+ if vol:
1137
+ table.append(f"- 成交量:{vol}。")
1138
+
1139
+ table.append("")
1140
+ table.append("Next" if _en else "下一步")
1141
+ table.append("- " + " · ".join(f"`/ta {symbol}`" for symbol in symbols[:4]) + (" — full technical chart" if _en else " — 完整技术图表"))
1142
+ table.append("- " + " · ".join(f"`/report {symbol}`" for symbol in symbols[:4]) + (" — generate research reports" if _en else " — 生成研究报告"))
1143
+ table.append("")
1144
+ table.append("*Not investment advice*" if _en else "*不构成投资建议*")
1145
+ return {
1146
+ "success": True,
1147
+ "response": "\n".join(table),
1148
+ "tools_used": ["market_snapshot"],
1149
+ "analysis_complete": True,
1150
+ }
1151
+
1152
+
1153
+ def _render_private_company_analysis(profile_key: str, message: str) -> dict:
1154
+ """Render a structured analysis for a private company using static profile data."""
1155
+ p = _PRIVATE_COMPANY_PROFILES.get(profile_key, {})
1156
+ if not p:
1157
+ return {"success": False, "error": "no_private_profile"}
1158
+
1159
+ name = p.get("name", profile_key)
1160
+ val = p.get("valuation_usd", "N/A")
1161
+ rev = p.get("rev_est", "N/A")
1162
+ growth = p.get("rev_growth", "N/A")
1163
+ comps = p.get("comparables", [])
1164
+
1165
+ lines = [
1166
+ f"## {name}",
1167
+ f"> ⚠️ **私有公司 — 无公开交易数据** 所有数字均来自公开报道与融资文件,非官方财报。",
1168
+ "",
1169
+ "### 估值与规模",
1170
+ f"| 指标 | 数据 |",
1171
+ f"|------|------|",
1172
+ f"| 最新估值 | **${val}B**({p.get('last_funding', 'N/A')})|",
1173
+ f"| 收入估算 | ~${rev}B/年(YoY +{growth}%)|",
1174
+ f"| 员工数量 | ~{p.get('employees', 'N/A')}k |",
1175
+ f"| 创立时间 | {p.get('founded', 'N/A')} — {p.get('founder', 'N/A')} |",
1176
+ f"| 总部 | {p.get('hq', 'N/A')} |",
1177
+ f"| IPO 状态 | {p.get('ipo_status', 'N/A')} |",
1178
+ "",
1179
+ ]
1180
+
1181
+ segs = p.get("segments", [])
1182
+ if segs:
1183
+ lines += ["### 业务板块", ""]
1184
+ for s in segs:
1185
+ lines.append(f"- {s}")
1186
+ lines.append("")
1187
+
1188
+ highlights = p.get("highlights", [])
1189
+ if highlights:
1190
+ lines += ["### 核心亮点", ""]
1191
+ for h in highlights:
1192
+ lines.append(f"✅ {h}")
1193
+ lines.append("")
1194
+
1195
+ risks = p.get("risks", [])
1196
+ if risks:
1197
+ lines += ["### 主要风险", ""]
1198
+ for r in risks:
1199
+ lines.append(f"⚠️ {r}")
1200
+ lines.append("")
1201
+
1202
+ if comps:
1203
+ comp_str = " · ".join(comps)
1204
+ lines += [
1205
+ "### 可比公司(均已上市)",
1206
+ f"> 可对比分析:{comp_str}",
1207
+ f"> 例如:`分析 {comps[0]} 的财务数据` 或 `/ta {comps[0]}` 查看技术面",
1208
+ "",
1209
+ ]
1210
+
1211
+ lines += [
1212
+ "---",
1213
+ "*数据来源:公开融资公告、新闻报道。私有公司无 SEC/证监会披露义务,以上估算存在较大不确定性。*",
1214
+ ]
1215
+
1216
+ return {
1217
+ "success": True,
1218
+ "response": "\n".join(lines),
1219
+ "tools_used": ["private_company_profile"],
1220
+ }
1221
+
1222
+
1223
+ def _resolve_etf_snapshot_symbols(message: str) -> list[str]:
1224
+ text = message or ""
1225
+ low = text.lower()
1226
+ if not any(k in low or k in text for k in ("etf", "基金", "交易型开放式")):
1227
+ return []
1228
+ if any(k in low or k in text for k in ("标普500", "标普 500", "s&p 500", "s&p500", "sp500", "spy")):
1229
+ return ["SPY", "VOO", "IVV"]
1230
+ if any(k in low or k in text for k in ("纳斯达克100", "纳斯达克 100", "nasdaq 100", "qqq")):
1231
+ return ["QQQ", "QQQM"]
1232
+ if any(k in low or k in text for k in ("黄金", "gold", "gld")):
1233
+ return ["GLD", "IAU"]
1234
+ return []
1235
+
1236
+
1237
+ _MARKET_OVERVIEW_INDICES = {
1238
+ "cn": {
1239
+ "label": "A 股市场",
1240
+ "yf": [("上证综指", "000001.SS"), ("深证成指", "399001.SZ"),
1241
+ ("创业板指", "399006.SZ"), ("沪深300", "000300.SS"),
1242
+ ("科创50", "000688.SS")],
1243
+ "ak_symbol": "沪深重要指数",
1244
+ "extras": ["北向资金", "涨跌家数"],
1245
+ },
1246
+ "us": {
1247
+ "label": "美股市场",
1248
+ "yf": [("道琼斯", "^DJI"), ("纳斯达克", "^IXIC"), ("标普500", "^GSPC"),
1249
+ ("罗素2000", "^RUT"), ("VIX 恐慌指数", "^VIX")],
1250
+ "ak_symbol": None,
1251
+ "extras": ["恐惧贪婪指数"],
1252
+ },
1253
+ "hk": {
1254
+ "label": "港股市场",
1255
+ "yf": [("恒生指数", "^HSI"), ("恒生国企", "^HSCE")],
1256
+ "ak_symbol": None,
1257
+ "extras": [],
1258
+ },
1259
+ }
1260
+
1261
+
1262
+ def _http_get_json(url: str, params: dict, timeout: int = 8):
1263
+ """GET JSON with a proxy-bypass retry.
1264
+
1265
+ A misconfigured/flaky HTTP(S)_PROXY (corporate or local) is a common cause
1266
+ of "ProxyError / connection aborted" on otherwise-reachable data sources.
1267
+ Try the normal request first (respecting the user's proxy), then retry with
1268
+ proxies disabled so the data still loads when only the proxy is broken.
1269
+ """
1270
+ import requests as _rq
1271
+ headers = {"User-Agent": "Mozilla/5.0"}
1272
+ for proxies in (None, {"http": None, "https": None}):
1273
+ try:
1274
+ r = _rq.get(url, params=params, timeout=timeout,
1275
+ headers=headers, proxies=proxies)
1276
+ r.raise_for_status()
1277
+ return r.json()
1278
+ except Exception:
1279
+ continue
1280
+ return None
1281
+
1282
+
1283
+ # 上证综指/深证成指/创业板指/沪深300/科创50 — eastmoney secids (1=SH, 0=SZ)
1284
+ _CN_INDEX_SECIDS = [
1285
+ ("上证综指", "1.000001"), ("深证成指", "0.399001"),
1286
+ ("创业板指", "0.399006"), ("沪深300", "1.000300"),
1287
+ ("科创50", "1.000688"),
1288
+ ]
1289
+
1290
+
1291
+ def _fetch_cn_indices_eastmoney() -> list:
1292
+ """Fetch CN index levels + change% directly from eastmoney ulist endpoint."""
1293
+ secids = ",".join(s for _, s in _CN_INDEX_SECIDS)
1294
+ data = _http_get_json(
1295
+ "https://push2.eastmoney.com/api/qt/ulist.np/get",
1296
+ {"secids": secids, "fields": "f2,f3,f12,f14", "fltt": 2, "invt": 2},
1297
+ )
1298
+ if not data:
1299
+ return []
1300
+ diff = (data.get("data") or {}).get("diff") or []
1301
+ # eastmoney may return a dict keyed by index or a list — normalize to list
1302
+ items = list(diff.values()) if isinstance(diff, dict) else diff
1303
+ disp_by_code = {sid.split(".")[1]: name for name, sid in _CN_INDEX_SECIDS}
1304
+ rows: list = []
1305
+ for it in items:
1306
+ code = str(it.get("f12", ""))
1307
+ name = disp_by_code.get(code) or str(it.get("f14", code))
1308
+ price = it.get("f2")
1309
+ chg = it.get("f3")
1310
+ if price in (None, "-", ""):
1311
+ continue
1312
+ try:
1313
+ rows.append({"name": name, "price": round(float(price), 2),
1314
+ "change_pct": round(float(chg), 2) if chg not in (None, "-", "") else 0.0,
1315
+ "ok": True})
1316
+ except (ValueError, TypeError):
1317
+ continue
1318
+ return rows
1319
+
1320
+
1321
+ def _fetch_overview_indices(market: str) -> list:
1322
+ """Fetch index levels for a market. Direct eastmoney (CN) / yfinance (US/HK).
1323
+
1324
+ Returns a list of {name, price, change_pct, ok} dicts; entries that could
1325
+ not be fetched are marked ok=False rather than dropped, so the user always
1326
+ sees the full index set and which data is temporarily unavailable.
1327
+ """
1328
+ cfg = _MARKET_OVERVIEW_INDICES.get(market, {})
1329
+ rows: list = []
1330
+
1331
+ # CN: direct eastmoney ulist endpoint — returns price + change% in one call
1332
+ # and is far more reliable than akshare's stock_zh_index_spot_em.
1333
+ if market == "cn":
1334
+ rows = _fetch_cn_indices_eastmoney()
1335
+ if rows:
1336
+ return rows
1337
+ # fall through to yfinance .SS as a last resort
1338
+
1339
+ # US / HK / CN-fallback via yfinance (5d window so change% always computes)
1340
+ try:
1341
+ import yfinance as yf
1342
+ for name, ticker in cfg.get("yf", []):
1343
+ try:
1344
+ h = yf.Ticker(ticker).history(period="5d")
1345
+ closes = [float(c) for c in h["Close"].tolist() if c == c] # drop NaN
1346
+ if closes:
1347
+ last = closes[-1]
1348
+ prev = closes[-2] if len(closes) >= 2 else last
1349
+ chg = ((last - prev) / prev * 100) if prev else 0.0
1350
+ rows.append({"name": name, "price": round(last, 2),
1351
+ "change_pct": round(chg, 2), "ok": True})
1352
+ else:
1353
+ rows.append({"name": name, "price": 0, "change_pct": 0, "ok": False})
1354
+ except Exception:
1355
+ rows.append({"name": name, "price": 0, "change_pct": 0, "ok": False})
1356
+ except Exception:
1357
+ pass
1358
+ return rows
1359
+
1360
+
1361
+ def _try_handle_market_overview(message: str) -> dict:
1362
+ """Deterministic whole-market overview for '分析A股/港股/美股市场行情'.
1363
+
1364
+ Answers a market-level question with the right index set instead of
1365
+ mis-parsing a market name into a single stock (the 'A股' → Agilent bug).
1366
+ """
1367
+ market = _detect_market_overview(message)
1368
+ if not market:
1369
+ return {"success": False, "error": "not_market_overview"}
1370
+
1371
+ cfg = _MARKET_OVERVIEW_INDICES[market]
1372
+ rows = _fetch_overview_indices(market)
1373
+ if not rows:
1374
+ return {"success": False, "error": "no_index_data"}
1375
+
1376
+ ok_rows = [r for r in rows if r.get("ok")]
1377
+ # Build a markdown overview
1378
+ lines = [f"## 📊 {cfg['label']}行情概览",
1379
+ f"*数据时间: {datetime.now().strftime('%Y-%m-%d %H:%M')} · 不构成投资建议*",
1380
+ "",
1381
+ "| 指数 | 最新点位 | 涨跌幅 |",
1382
+ "|------|---------|--------|"]
1383
+ for r in rows:
1384
+ if r.get("ok"):
1385
+ arrow = "🔴" if r["change_pct"] < 0 else ("🟢" if r["change_pct"] > 0 else "⚪")
1386
+ lines.append(f"| {r['name']} | {r['price']:,.2f} | {arrow} {r['change_pct']:+.2f}% |")
1387
+ else:
1388
+ lines.append(f"| {r['name']} | — | 数据暂不可用 |")
1389
+
1390
+ # Breadth summary from the indices we did get
1391
+ if ok_rows:
1392
+ up = sum(1 for r in ok_rows if r["change_pct"] > 0)
1393
+ down = sum(1 for r in ok_rows if r["change_pct"] < 0)
1394
+ avg = sum(r["change_pct"] for r in ok_rows) / len(ok_rows)
1395
+ if avg > 0.5:
1396
+ tone = "整体偏强,多数指数上涨"
1397
+ elif avg < -0.5:
1398
+ tone = "整体偏弱,多数指数下跌"
1399
+ else:
1400
+ tone = "涨跌互现,方向不明"
1401
+ lines += ["", f"**概况**: {tone}({up} 涨 / {down} 跌,均值 {avg:+.2f}%)"]
1402
+
1403
+ # Market-specific next steps
1404
+ _next = {
1405
+ "cn": ["`/north` — 北向资金流向", "`/limitup` — 涨停板复盘",
1406
+ "`/quote 600519` — 个股报价(如贵州茅台)"],
1407
+ "us": ["`/quote AAPL MSFT NVDA` — 龙头个股", "`/sector` — 板块表现",
1408
+ "`fear greed` — 市场情绪指数"],
1409
+ "hk": ["`/quote 0700.HK` — 腾讯等个股", "`/north` — 南向资金"],
1410
+ }.get(market, [])
1411
+ if _next:
1412
+ lines += ["", "**下一步**"] + [f"- {s}" for s in _next]
1413
+
1414
+ return {"success": True, "response": "\n".join(lines),
1415
+ "tools_used": ["market_overview"]}
1416
+
1417
+
1418
+ def _try_handle_market_snapshot_analysis(message: str, history: list = None) -> dict:
1419
+ """Deterministic path for simple market analysis.
1420
+
1421
+ Local small models tend to mangle injected quote fields into fragments like
1422
+ "N/A/N/A/-1.24%". For snapshot requests, format the data directly.
1423
+ """
1424
+ if not _is_market_snapshot_request(message, history):
1425
+ return {"success": False, "error": "not_market_snapshot"}
1426
+
1427
+ _etf_symbols = _resolve_etf_snapshot_symbols(message)
1428
+ if len(_etf_symbols) >= 2:
1429
+ return _try_handle_multi_market_snapshot(message, _etf_symbols)
1430
+
1431
+ _symbols = _extract_market_symbols(message)
1432
+ if len(_symbols) >= 2:
1433
+ return _try_handle_multi_market_snapshot(message, _symbols)
1434
+
1435
+ _msg_sym = _extract_market_symbol(message)
1436
+
1437
+ # Private company: PRIVATE:Name — render static profile instead of live data
1438
+ if _msg_sym and _msg_sym.startswith("PRIVATE:"):
1439
+ return _render_private_company_analysis(_msg_sym[len("PRIVATE:"):], message)
1440
+
1441
+ _hist_sym = (_extract_symbol_from_history(history) if history else "") if not _msg_sym else ""
1442
+
1443
+ # Guard: if message names an unrecognised company, don't silently use history symbol
1444
+ if not _msg_sym and _has_unresolved_company_mention(message):
1445
+ return {
1446
+ "success": True,
1447
+ "response": (
1448
+ "## ❓ 无法识别的股票\n\n"
1449
+ "未能将消息中提到的公司/品牌解析为已知股票代码。\n\n"
1450
+ "请提供具体代码后重试,例如:\n"
1451
+ "- A股:`/quote 600519`(贵州茅台)\n"
1452
+ "- 港股:`/quote 0700.HK`(腾讯)\n"
1453
+ "- 美股:`/quote AAPL`\n"
1454
+ "- 欧洲:`/quote MC.PA`(LVMH/路易威登)\n\n"
1455
+ "*提示:如需全局搜索,可输入 `/ta <代码>` 获取完整技术分析。*"
1456
+ ),
1457
+ "tools_used": ["market_snapshot"],
1458
+ "analysis_complete": True,
1459
+ }
1460
+
1461
+ symbol = _msg_sym or _hist_sym or "AAPL"
1462
+ def _snapshot_ashare_code(sym: str) -> str:
1463
+ s = str(sym or "").strip().upper()
1464
+ if s.endswith((".SZ", ".SS", ".SH")):
1465
+ s = s.rsplit(".", 1)[0]
1466
+ if s.startswith(("SH", "SZ")) and s[2:].isdigit() and len(s[2:]) == 6:
1467
+ s = s[2:]
1468
+ return s if s.isdigit() and len(s) == 6 else ""
1469
+
1470
+ _ashare_code = _snapshot_ashare_code(symbol)
1471
+ if not _has_mdc_lazy():
1472
+ return {
1473
+ "success": True,
1474
+ "response": (
1475
+ f"## {symbol} 市场快照\n\n"
1476
+ "当前本地行情客户端未加载,无法获取实时行情。\n\n"
1477
+ f"可运行 `/quote {symbol}` 重试。"
1478
+ ),
1479
+ "tools_used": ["market_snapshot"],
1480
+ "analysis_complete": True,
1481
+ }
1482
+
1483
+ import time as _time_snap
1484
+
1485
+ def _clean_network_error(raw: str) -> str:
1486
+ """Convert raw exception strings to readable Chinese messages."""
1487
+ if "Connection aborted" in raw or "RemoteDisconnected" in raw:
1488
+ return "网络连接被中断(服务器关闭连接),请稍后重试"
1489
+ if "Connection refused" in raw:
1490
+ return "连接被拒绝,数据服务暂时不可用"
1491
+ if "timeout" in raw.lower() or "timed out" in raw.lower():
1492
+ return "连接超时,请稍后重试"
1493
+ if "NoneType" in raw or raw.strip() in ("None", ""):
1494
+ return "数据源未返回有效价格"
1495
+ return raw
1496
+
1497
+ quote = {"success": False, "error": "未初始化"}
1498
+ _snapshot_quality = {}
1499
+ import contextlib as _ctxlib_snapshot
1500
+ import io as _io_snapshot
1501
+ for _attempt in range(3):
1502
+ try:
1503
+ mdc = _get_mdc_lazy()
1504
+ try:
1505
+ from packages.aria_services.data import DataService as _SnapshotDataService
1506
+ with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
1507
+ _quote_result = _SnapshotDataService(market_client=mdc, router=False).quote(symbol)
1508
+ quote = _quote_result.data or {}
1509
+ _snapshot_quality = _quote_result.quality or {}
1510
+ if _quote_result.provider_chain:
1511
+ quote.setdefault("provider_chain", _quote_result.provider_chain)
1512
+ quote.setdefault("success", bool(_quote_result.success))
1513
+ if not quote.get("success"):
1514
+ with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
1515
+ raw_quote = mdc.quote(symbol)
1516
+ if raw_quote:
1517
+ quote = raw_quote if isinstance(raw_quote, dict) else (
1518
+ raw_quote.to_dict() if hasattr(raw_quote, "to_dict") else vars(raw_quote)
1519
+ )
1520
+ except Exception:
1521
+ with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
1522
+ quote = mdc.quote(symbol)
1523
+ if quote.get("success"):
1524
+ break
1525
+ _err_str = str(quote.get("error", ""))
1526
+ _err_lower = _err_str.lower()
1527
+ # Clean raw exception strings in-place
1528
+ if any(k in _err_str for k in ("Connection aborted", "RemoteDisconnected",
1529
+ "Connection refused", "timeout")):
1530
+ quote["error"] = _clean_network_error(_err_str)
1531
+ # Retry on connection errors AND rate limits
1532
+ _should_retry = (
1533
+ ("rate" in _err_lower or "429" in _err_lower or "too many" in _err_lower)
1534
+ or ("connection aborted" in _err_lower or "remotedisconnected" in _err_lower)
1535
+ )
1536
+ if _should_retry and _attempt < 2:
1537
+ _time_snap.sleep(1 + _attempt) # 1s, 2s
1538
+ continue
1539
+ break
1540
+ except Exception as exc:
1541
+ _raw_exc = str(exc)
1542
+ _exc_lower = _raw_exc.lower()
1543
+ _clean_err = _clean_network_error(_raw_exc)
1544
+ _should_retry = (
1545
+ ("rate" in _exc_lower or "429" in _exc_lower or "too many" in _exc_lower)
1546
+ or ("connection aborted" in _exc_lower or "remotedisconnected" in _exc_lower)
1547
+ )
1548
+ if _should_retry and _attempt < 2:
1549
+ _time_snap.sleep(1 + _attempt)
1550
+ continue
1551
+ quote = {"success": False, "error": _clean_err}
1552
+ break
1553
+
1554
+ # Finnhub fallback when primary data source (yfinance) failed or rate-limited
1555
+ # _get_provider_key reads both env vars AND ~/.arthera/providers.json
1556
+ # NOTE: do NOT use dir() — it returns local scope, not module globals.
1557
+ _fh_key = _get_provider_key("finnhub")
1558
+ _fh_tried = False
1559
+ if not quote.get("success") and _fh_key:
1560
+ _fh_tried = True
1561
+ try:
1562
+ import requests as _rq
1563
+ _fh_r = _rq.get(
1564
+ f"https://finnhub.io/api/v1/quote?symbol={symbol}&token={_fh_key}",
1565
+ timeout=6
1566
+ )
1567
+ if _fh_r.status_code == 200:
1568
+ _fh = _fh_r.json()
1569
+ if _fh.get("c"): # current price present
1570
+ quote = {
1571
+ "success": True, "symbol": symbol,
1572
+ "price": round(_fh["c"], 2),
1573
+ "change_pct": round(float(_fh.get("dp") or 0), 2),
1574
+ "high": round(_fh.get("h", 0), 2),
1575
+ "low": round(_fh.get("l", 0), 2),
1576
+ "currency": "USD", "provider": "finnhub",
1577
+ }
1578
+ except Exception:
1579
+ pass
1580
+
1581
+ # akshare fallback for A-shares when both yfinance and eastmoney fail
1582
+ _is_a_share_sym = bool(_ashare_code)
1583
+ if _is_a_share_sym and not quote.get("success"):
1584
+ try:
1585
+ import akshare as _ak
1586
+ from datetime import datetime as _dt2, timedelta as _td2
1587
+ _end_d = _dt2.now().strftime("%Y%m%d")
1588
+ _start_d = (_dt2.now() - _td2(days=7)).strftime("%Y%m%d")
1589
+ _df_q = _ak.stock_zh_a_hist(
1590
+ symbol=_ashare_code, period="daily",
1591
+ start_date=_start_d, end_date=_end_d, adjust=""
1592
+ )
1593
+ if not _df_q.empty:
1594
+ _row = _df_q.iloc[-1]
1595
+ _close = float(_row.get("收盘", 0))
1596
+ _prev = float(_df_q.iloc[-2]["收盘"]) if len(_df_q) >= 2 else _close
1597
+ _chg_p = round((_close - _prev) / _prev * 100, 2) if _prev else 0
1598
+ _name = symbol
1599
+ try:
1600
+ _info_df = _ak.stock_individual_info_em(symbol=_ashare_code)
1601
+ _name = str(_info_df[_info_df["item"] == "股票简称"]["value"].values[0])
1602
+ except Exception:
1603
+ pass
1604
+ quote = {
1605
+ "success": True, "symbol": symbol, "name": _name,
1606
+ "price": _close, "change_pct": _chg_p,
1607
+ "high": float(_row.get("最高", _close)),
1608
+ "low": float(_row.get("最低", _close)),
1609
+ "volume": int(_row.get("成交量", 0)),
1610
+ "currency": "CNY", "provider": "akshare",
1611
+ }
1612
+ except Exception:
1613
+ pass
1614
+
1615
+ def _num(v):
1616
+ try:
1617
+ if v in (None, "", "N/A", "-", "nan"):
1618
+ return None
1619
+ return float(v)
1620
+ except Exception:
1621
+ return None
1622
+
1623
+ price = _num(quote.get("price"))
1624
+ # yfinance fallback when price is 0 or None (e.g. EDGAR source returns 0.00)
1625
+ if price is None or price == 0:
1626
+ try:
1627
+ import yfinance as _yf_single
1628
+ _t_s = _yf_single.Ticker(symbol)
1629
+ _fi_s = _t_s.fast_info
1630
+ _yf_p = getattr(_fi_s, "last_price", None) or getattr(_fi_s, "previous_close", None)
1631
+ if _yf_p and float(_yf_p) > 0:
1632
+ _yf_prev_s = getattr(_fi_s, "previous_close", _yf_p)
1633
+ _yf_chg_s = (float(_yf_p) - float(_yf_prev_s)) / float(_yf_prev_s) * 100 if _yf_prev_s else 0
1634
+ _yf_info_s = {}
1635
+ try:
1636
+ _yf_info_s = _t_s.info or {}
1637
+ except Exception:
1638
+ pass
1639
+ price = round(float(_yf_p), 2)
1640
+ quote = {
1641
+ "success": True, "symbol": symbol,
1642
+ "name": _yf_info_s.get("shortName") or _yf_info_s.get("longName") or symbol,
1643
+ "price": price,
1644
+ "change_pct": round(_yf_chg_s, 2),
1645
+ "currency": _yf_info_s.get("currency") or "USD",
1646
+ "market_cap": _yf_info_s.get("marketCap") or 0,
1647
+ "provider": "yfinance",
1648
+ }
1649
+ except Exception:
1650
+ pass
1651
+ price = _num(quote.get("price"))
1652
+
1653
+ if not quote.get("success") or price is None or price == 0:
1654
+ err = quote.get("error") or "当前数据源未返回有效价格"
1655
+ if "NoneType" in str(err):
1656
+ err = "当前数据源未返回有效价格"
1657
+ is_rate_limit = "rate" in str(err).lower() or "429" in str(err) or "too many" in str(err).lower()
1658
+ if is_rate_limit:
1659
+ if _fh_tried:
1660
+ # Finnhub was tried but also failed — both sources exhausted
1661
+ _hint = "\n\n[提示] yfinance 和 Finnhub 均触发频率限制,请稍等 30 秒后重试。"
1662
+ elif _fh_key:
1663
+ # Key configured but Finnhub wasn't tried (shouldn't happen, but defensive)
1664
+ _hint = "\n\n[提示] 数据源请求频率受限,请稍等 30 秒后重试。"
1665
+ else:
1666
+ # No Finnhub key — suggest configuring one
1667
+ _hint = (
1668
+ "\n\n[提示] 数据源请求频率受限:请稍等 30 秒后重试,"
1669
+ "或配置 Finnhub key 使用备用数据源:`/apikey set finnhub <key>`"
1670
+ "(注册:https://finnhub.io/register)"
1671
+ )
1672
+ else:
1673
+ _hint = ""
1674
+ return {
1675
+ "success": True,
1676
+ "response": (
1677
+ f"## {symbol} 市场快照\n\n"
1678
+ f"当前无法获取有效行情:{err}{_hint}\n\n"
1679
+ f"可运行 `/quote {symbol}` 重试;在数据恢复前不输出 RSI、MACD 或支撑/阻力位。"
1680
+ ),
1681
+ "tools_used": ["market_snapshot"],
1682
+ "rate_limited": is_rate_limit,
1683
+ "analysis_complete": True,
1684
+ }
1685
+
1686
+ name = quote.get("name") or symbol
1687
+ currency = quote.get("currency") or "USD"
1688
+ chg = _num(quote.get("change_pct"))
1689
+ high = _num(quote.get("high"))
1690
+ low = _num(quote.get("low"))
1691
+ volume = quote.get("volume")
1692
+ market_cap_raw = _num(quote.get("market_cap"))
1693
+ provider = quote.get("provider") or "market_data_client"
1694
+ sign = "+" if (chg or 0) >= 0 else ""
1695
+ chg_str = f"{sign}{chg:.2f}%" if chg is not None else "—"
1696
+ range_str = f"{currency} {low:,.2f} - {currency} {high:,.2f}" if low is not None and high is not None else ""
1697
+ # Format market cap: T / B / M abbreviation
1698
+ if market_cap_raw and market_cap_raw > 0:
1699
+ if market_cap_raw >= 1e12:
1700
+ _mktcap_str = f"{currency} {market_cap_raw/1e12:.2f}T"
1701
+ elif market_cap_raw >= 1e9:
1702
+ _mktcap_str = f"{currency} {market_cap_raw/1e9:.1f}B"
1703
+ elif market_cap_raw >= 1e6:
1704
+ _mktcap_str = f"{currency} {market_cap_raw/1e6:.0f}M"
1705
+ else:
1706
+ _mktcap_str = f"{currency} {market_cap_raw:,.0f}"
1707
+ else:
1708
+ _mktcap_str = None
1709
+
1710
+ # ── Technical indicators: mdc → akshare(A股) → yfinance → Finnhub ────────
1711
+ ti = {}
1712
+ try:
1713
+ with _ctxlib_snapshot.redirect_stdout(_io_snapshot.StringIO()), _ctxlib_snapshot.redirect_stderr(_io_snapshot.StringIO()):
1714
+ ti = mdc.technical_indicators(symbol, days=120)
1715
+ except Exception:
1716
+ ti = {}
1717
+
1718
+ # Akshare fallback for A-shares (6-digit code, no suffix) — more reliable than yfinance for CN
1719
+ _is_a_share = bool(_ashare_code)
1720
+ if (_is_a_share and (not ti.get("success") or ti.get("rsi") is None)):
1721
+ try:
1722
+ import akshare as _ak
1723
+ import numpy as _np_ak
1724
+ from datetime import datetime as _dt, timedelta as _td
1725
+ _ak_start = (_dt.now() - _td(days=200)).strftime("%Y%m%d")
1726
+ _ak_end = _dt.now().strftime("%Y%m%d")
1727
+ _df_ak = _ak.stock_zh_a_hist(
1728
+ symbol=_ashare_code, period="daily",
1729
+ start_date=_ak_start, end_date=_ak_end,
1730
+ adjust="qfq",
1731
+ )
1732
+ _col_map = {
1733
+ "收盘": "Close", "成交量": "Volume",
1734
+ "close": "Close", "volume": "Volume",
1735
+ }
1736
+ _df_ak = _df_ak.rename(columns=_col_map)
1737
+ if "Close" in _df_ak.columns and len(_df_ak) >= 20:
1738
+ _c_ak = _df_ak["Close"].astype(float)
1739
+ _v_ak = _df_ak["Volume"].astype(float) if "Volume" in _df_ak.columns else None
1740
+ # RSI(14)
1741
+ _d_ak = _c_ak.diff()
1742
+ _g_ak = _d_ak.clip(lower=0).rolling(14).mean()
1743
+ _l_ak = (-_d_ak.clip(upper=0)).rolling(14).mean()
1744
+ _rs_ak = _g_ak / _l_ak.replace(0, _np_ak.nan)
1745
+ _rsi_ak = float((100 - 100 / (1 + _rs_ak)).iloc[-1])
1746
+ # MACD
1747
+ _ema12_ak = _c_ak.ewm(span=12).mean()
1748
+ _ema26_ak = _c_ak.ewm(span=26).mean()
1749
+ _macd_ak = _ema12_ak - _ema26_ak
1750
+ _sig_ak = _macd_ak.ewm(span=9).mean()
1751
+ _mhist_ak = float((_macd_ak - _sig_ak).iloc[-1])
1752
+ # MA / BB
1753
+ _ma20_ak = _c_ak.rolling(20).mean()
1754
+ _std20_ak = _c_ak.rolling(20).std()
1755
+ _ma60_ak = _c_ak.rolling(60).mean() if len(_c_ak) >= 60 else _ma20_ak
1756
+ ti = {
1757
+ "success": True,
1758
+ "rsi": round(_rsi_ak, 2) if not _np_ak.isnan(_rsi_ak) else None,
1759
+ "macd_hist": round(_mhist_ak, 4),
1760
+ "ma20": round(float(_ma20_ak.iloc[-1]), 2),
1761
+ "ma60": round(float(_ma60_ak.iloc[-1]), 2),
1762
+ "bb_upper": round(float((_ma20_ak + 2*_std20_ak).iloc[-1]), 2),
1763
+ "bb_lower": round(float((_ma20_ak - 2*_std20_ak).iloc[-1]), 2),
1764
+ "provider": "akshare",
1765
+ }
1766
+ if volume is None and _v_ak is not None:
1767
+ _rv = _v_ak.iloc[-1]
1768
+ if not _np_ak.isnan(_rv):
1769
+ volume = int(_rv)
1770
+ except Exception:
1771
+ pass
1772
+
1773
+ # If mdc returned nothing useful (all None), try yfinance directly
1774
+ if (not ti.get("success") or ti.get("rsi") is None) and not _is_a_share:
1775
+ try:
1776
+ import yfinance as _yf
1777
+ import numpy as _np
1778
+ # A股裸6位代码需要 yfinance 后缀:6/68开头→.SS,其余→.SZ
1779
+ _yf_sym = symbol
1780
+ if symbol.isdigit() and len(symbol) == 6:
1781
+ _yf_sym = symbol + (".SS" if symbol.startswith("6") else ".SZ")
1782
+ _hist = _yf.Ticker(_yf_sym).history(period="6mo")
1783
+ if len(_hist) >= 20:
1784
+ _close = _hist["Close"]
1785
+ _vol = _hist["Volume"]
1786
+ # RSI(14)
1787
+ _d = _close.diff()
1788
+ _g = _d.clip(lower=0).rolling(14).mean()
1789
+ _l = (-_d.clip(upper=0)).rolling(14).mean()
1790
+ _rs = _g / _l.replace(0, _np.nan)
1791
+ _rsi = float((100 - 100 / (1 + _rs)).iloc[-1])
1792
+ # MACD hist
1793
+ _ema12 = _close.ewm(span=12).mean()
1794
+ _ema26 = _close.ewm(span=26).mean()
1795
+ _macd = _ema12 - _ema26
1796
+ _signal = _macd.ewm(span=9).mean()
1797
+ _mhist = float((_macd - _signal).iloc[-1])
1798
+ # Bollinger Bands & MA
1799
+ _ma20 = _close.rolling(20).mean()
1800
+ _std20 = _close.rolling(20).std()
1801
+ _ma60 = _close.rolling(60).mean() if len(_close) >= 60 else _ma20
1802
+ ti = {
1803
+ "success": True,
1804
+ "rsi": round(_rsi, 2) if not _np.isnan(_rsi) else None,
1805
+ "macd_hist": round(_mhist, 4),
1806
+ "ma20": round(float(_ma20.iloc[-1]), 2),
1807
+ "ma60": round(float(_ma60.iloc[-1]), 2),
1808
+ "bb_upper": round(float((_ma20 + 2 * _std20).iloc[-1]), 2),
1809
+ "bb_lower": round(float((_ma20 - 2 * _std20).iloc[-1]), 2),
1810
+ "provider": "yfinance_direct",
1811
+ }
1812
+ # Back-fill volume if missing from quote
1813
+ if volume is None or str(volume) in ("None", "N/A", ""):
1814
+ _recent_vol = _vol.iloc[-1]
1815
+ if not _np.isnan(_recent_vol):
1816
+ volume = int(_recent_vol)
1817
+ except Exception:
1818
+ pass
1819
+
1820
+ # Finnhub candle fallback: when price came from Finnhub but yfinance TA failed,
1821
+ # fetch 6-month daily candles from Finnhub to compute RSI/MACD/MA.
1822
+ # Only attempted for US-style symbols (no A-share 6-digit codes, no .HK).
1823
+ _is_us_sym = bool(symbol and not symbol.isdigit() and "." not in symbol)
1824
+ if (not ti.get("success") or ti.get("rsi") is None) and _fh_key and _is_us_sym:
1825
+ try:
1826
+ import requests as _rq2, time as _t2
1827
+ _to_ts = int(_t2.time())
1828
+ _from_ts = _to_ts - 180 * 86400 # ~6 months
1829
+ _cr = _rq2.get(
1830
+ f"https://finnhub.io/api/v1/stock/candle"
1831
+ f"?symbol={symbol}&resolution=D&from={_from_ts}&to={_to_ts}&token={_fh_key}",
1832
+ timeout=8,
1833
+ )
1834
+ if _cr.status_code == 200:
1835
+ _cd = _cr.json()
1836
+ if _cd.get("s") == "ok" and _cd.get("c") and len(_cd["c"]) >= 20:
1837
+ import numpy as _np2
1838
+ _c = _cd["c"] # close prices list
1839
+ _v = _cd.get("v", [])
1840
+ import statistics as _st
1841
+ # RSI(14) — simple loop (no pandas needed)
1842
+ _gains, _losses = [], []
1843
+ for i in range(1, len(_c)):
1844
+ _delta = _c[i] - _c[i-1]
1845
+ _gains.append(max(_delta, 0))
1846
+ _losses.append(max(-_delta, 0))
1847
+ _ag = sum(_gains[:14]) / 14
1848
+ _al = sum(_losses[:14]) / 14
1849
+ for i in range(14, len(_gains)):
1850
+ _ag = (_ag * 13 + _gains[i]) / 14
1851
+ _al = (_al * 13 + _losses[i]) / 14
1852
+ _rsi_fh = (100 - 100 / (1 + _ag / _al)) if _al else 100
1853
+ # MACD(12,26,9)
1854
+ def _ema_list(prices, span):
1855
+ k, result_ema = 2/(span+1), [prices[0]]
1856
+ for p in prices[1:]: result_ema.append(p*k + result_ema[-1]*(1-k))
1857
+ return result_ema
1858
+ _ema12_fh = _ema_list(_c, 12)
1859
+ _ema26_fh = _ema_list(_c, 26)
1860
+ _macd_fh = [a - b for a, b in zip(_ema12_fh, _ema26_fh)]
1861
+ _sig_fh = _ema_list(_macd_fh, 9)
1862
+ _mhist_fh = _macd_fh[-1] - _sig_fh[-1]
1863
+ # MA20 / MA60
1864
+ _ma20_fh = sum(_c[-20:]) / 20
1865
+ _ma60_fh = sum(_c[-60:]) / 60 if len(_c) >= 60 else _ma20_fh
1866
+ # Bollinger Bands
1867
+ _std20_fh = _st.stdev(_c[-20:])
1868
+ ti = {
1869
+ "success": True,
1870
+ "rsi": round(_rsi_fh, 2),
1871
+ "macd_hist": round(_mhist_fh, 4),
1872
+ "ma20": round(_ma20_fh, 2),
1873
+ "ma60": round(_ma60_fh, 2),
1874
+ "bb_upper": round(_ma20_fh + 2*_std20_fh, 2),
1875
+ "bb_lower": round(_ma20_fh - 2*_std20_fh, 2),
1876
+ "provider": "finnhub_candle",
1877
+ }
1878
+ if volume is None and _v:
1879
+ volume = int(_v[-1]) if _v[-1] else None
1880
+ except Exception:
1881
+ pass
1882
+
1883
+ # Yahoo Finance v8 direct API — different endpoint from yfinance, avoids rate-limit collision.
1884
+ # Used when yfinance (via MDC) AND Finnhub candle both fail to produce TA data.
1885
+ # Only for non-A-share symbols; A-shares use akshare which has its own path above.
1886
+ if (not ti.get("success") or ti.get("rsi") is None) and _is_us_sym:
1887
+ try:
1888
+ import json as _json_yv8, urllib.request as _urlreq_yv8
1889
+ _yv8_url = (
1890
+ f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
1891
+ "?interval=1d&range=6mo"
1892
+ )
1893
+ _yv8_req = _urlreq_yv8.Request(_yv8_url, headers={
1894
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
1895
+ "Accept": "application/json",
1896
+ })
1897
+ with _urlreq_yv8.urlopen(_yv8_req, timeout=10) as _yv8_resp:
1898
+ _yv8_data = _json_yv8.loads(_yv8_resp.read())
1899
+ _yv8_result = _yv8_data["chart"]["result"][0]
1900
+ _yv8_q = _yv8_result["indicators"]["quote"][0]
1901
+ _c_yv8 = [x for x in _yv8_q.get("close", []) if x is not None]
1902
+ _v_yv8 = [x for x in _yv8_q.get("volume", []) if x is not None]
1903
+ if len(_c_yv8) >= 26:
1904
+ _c = _c_yv8
1905
+ # RSI(14) — Wilder EMA
1906
+ _d = [_c[i] - _c[i-1] for i in range(1, len(_c))]
1907
+ _g = [max(x, 0) for x in _d]; _l = [max(-x, 0) for x in _d]
1908
+ _ag = sum(_g[:14]) / 14; _al = sum(_l[:14]) / 14
1909
+ for i in range(14, len(_g)):
1910
+ _ag = (_ag * 13 + _g[i]) / 14; _al = (_al * 13 + _l[i]) / 14
1911
+ _rsi_yv8 = (100 - 100 / (1 + _ag / _al)) if _al else 100.0
1912
+ # MACD(12,26,9)
1913
+ def _ema_yv8(prices, span):
1914
+ k, r = 2 / (span + 1), [prices[0]]
1915
+ for p in prices[1:]: r.append(p * k + r[-1] * (1 - k))
1916
+ return r
1917
+ _ema12 = _ema_yv8(_c, 12); _ema26 = _ema_yv8(_c, 26)
1918
+ _macd = [a - b for a, b in zip(_ema12, _ema26)]
1919
+ _sig = _ema_yv8(_macd, 9)
1920
+ _mhist_yv8 = _macd[-1] - _sig[-1]
1921
+ # MA20 / MA60 / Bollinger
1922
+ _ma20_yv8 = sum(_c[-20:]) / 20
1923
+ _ma60_yv8 = sum(_c[-60:]) / 60 if len(_c) >= 60 else _ma20_yv8
1924
+ import statistics as _st_yv8
1925
+ _std20_yv8 = _st_yv8.stdev(_c[-20:])
1926
+ ti = {
1927
+ "success": True,
1928
+ "rsi": round(_rsi_yv8, 2),
1929
+ "macd_hist": round(_mhist_yv8, 4),
1930
+ "ma20": round(_ma20_yv8, 2),
1931
+ "ma60": round(_ma60_yv8, 2),
1932
+ "bb_upper": round(_ma20_yv8 + 2 * _std20_yv8, 2),
1933
+ "bb_lower": round(_ma20_yv8 - 2 * _std20_yv8, 2),
1934
+ "provider": "yahoo_v8",
1935
+ }
1936
+ if volume is None and _v_yv8:
1937
+ volume = int(_v_yv8[-1])
1938
+ except Exception:
1939
+ pass
1940
+
1941
+ rsi = _num(ti.get("rsi"))
1942
+ mhist = _num(ti.get("macd_hist"))
1943
+ ma20 = _num(ti.get("ma20"))
1944
+ ma60 = _num(ti.get("ma60"))
1945
+ bbu = _num(ti.get("bb_upper"))
1946
+ bbl = _num(ti.get("bb_lower"))
1947
+
1948
+ if rsi is None:
1949
+ rsi_view = "—"
1950
+ elif rsi >= 70:
1951
+ rsi_view = f"{rsi:.1f},超买风险"
1952
+ elif rsi <= 30:
1953
+ rsi_view = f"{rsi:.1f},超卖反弹可能"
1954
+ else:
1955
+ rsi_view = f"{rsi:.1f},中性"
1956
+
1957
+ if mhist is None:
1958
+ macd_view = "—"
1959
+ else:
1960
+ macd_view = f"{mhist:.4f},{'偏多' if mhist > 0 else '偏空'}"
1961
+
1962
+ supports = [v for v in (bbl, ma60, ma20) if v is not None and v < price]
1963
+ resistances = [v for v in (ma20, ma60, bbu) if v is not None and v > price]
1964
+ supports = sorted(set(round(v, 2) for v in supports), reverse=True)[:3]
1965
+ resistances = sorted(set(round(v, 2) for v in resistances))[:3]
1966
+ support_str = ", ".join(f"{currency} {v:,.2f}" for v in supports)
1967
+ resistance_str = ", ".join(f"{currency} {v:,.2f}" for v in resistances)
1968
+ timeframe_levels = _build_timeframe_levels(
1969
+ locals().get("mdc"),
1970
+ symbol,
1971
+ price,
1972
+ currency,
1973
+ ma20=ma20,
1974
+ ma60=ma60,
1975
+ bb_lower=bbl,
1976
+ bb_upper=bbu,
1977
+ fallback_supports=supports,
1978
+ fallback_resistances=resistances,
1979
+ )
1980
+
1981
+ # ── Signal logic ──────────────────────────────────────────────────────────
1982
+ _enough_data = (rsi is not None) or (mhist is not None)
1983
+
1984
+ _SIGNAL_LABELS: dict[str, dict[str, str]] = {
1985
+ "zh": {
1986
+ "STRONG_BUY": "强势多头 — 趋势与动量共振",
1987
+ "BUY": "偏多 — 量化条件支持上行",
1988
+ "SELL": "偏空 — 量化条件提示下行",
1989
+ "STRONG_SELL": "强势空头 — 趋势与动量共振下行",
1990
+ "CAUTION": "超买 — 等待回调确认",
1991
+ "WATCH": "超卖 — 关注企稳信号",
1992
+ "HOLD+": "短线偏强,控制仓位",
1993
+ "HOLD−": "短线偏弱,守住支撑",
1994
+ "NEUTRAL": "震荡观察,等待方向",
1995
+ "—": "指标数据不足",
1996
+ },
1997
+ "en": {
1998
+ "STRONG_BUY": "Strong bullish — trend and momentum aligned",
1999
+ "BUY": "Bullish — quantitative setup supports upside",
2000
+ "SELL": "Bearish — quantitative setup warns downside",
2001
+ "STRONG_SELL": "Strong bearish — trend and momentum aligned lower",
2002
+ "CAUTION": "Overbought — wait for pullback",
2003
+ "WATCH": "Oversold — watch for stabilization",
2004
+ "HOLD+": "Short-term bias up, manage size",
2005
+ "HOLD−": "Short-term bias down, hold support",
2006
+ "NEUTRAL": "Ranging — wait for direction",
2007
+ "—": "Insufficient indicator data",
2008
+ },
2009
+ }
2010
+
2011
+ if _enough_data:
2012
+ _signal_score = 0
2013
+ _signal_reasons = []
2014
+ if ma20 is not None:
2015
+ if price > ma20:
2016
+ _signal_score += 1
2017
+ _signal_reasons.append("price>MA20")
2018
+ else:
2019
+ _signal_score -= 1
2020
+ _signal_reasons.append("price<MA20")
2021
+ if ma60 is not None:
2022
+ if price > ma60:
2023
+ _signal_score += 1
2024
+ _signal_reasons.append("price>MA60")
2025
+ else:
2026
+ _signal_score -= 1
2027
+ _signal_reasons.append("price<MA60")
2028
+ if mhist is not None:
2029
+ if mhist > 0:
2030
+ _signal_score += 1
2031
+ _signal_reasons.append("MACD+")
2032
+ elif mhist < 0:
2033
+ _signal_score -= 1
2034
+ _signal_reasons.append("MACD-")
2035
+ if rsi is not None:
2036
+ if rsi <= 30:
2037
+ _signal_score += 2
2038
+ _signal_reasons.append("RSI oversold")
2039
+ elif rsi <= 40:
2040
+ _signal_score += 1
2041
+ _signal_reasons.append("RSI low")
2042
+ elif rsi >= 75:
2043
+ _signal_score -= 2
2044
+ _signal_reasons.append("RSI very high")
2045
+ elif rsi >= 65:
2046
+ _signal_score -= 1
2047
+ _signal_reasons.append("RSI high")
2048
+ if chg is not None:
2049
+ if chg >= 2:
2050
+ _signal_score += 1
2051
+ _signal_reasons.append("day momentum+")
2052
+ elif chg <= -2:
2053
+ _signal_score -= 1
2054
+ _signal_reasons.append("day momentum-")
2055
+
2056
+ if _signal_score >= 4:
2057
+ _sig_key = "STRONG_BUY"
2058
+ elif _signal_score >= 2:
2059
+ _sig_key = "BUY"
2060
+ elif _signal_score <= -4:
2061
+ _sig_key = "STRONG_SELL"
2062
+ elif _signal_score <= -2:
2063
+ _sig_key = "SELL"
2064
+ elif _signal_score == 1:
2065
+ _sig_key = "HOLD+"
2066
+ elif _signal_score == -1:
2067
+ _sig_key = "HOLD−"
2068
+ else:
2069
+ _sig_key = "NEUTRAL"
2070
+ else:
2071
+ _sig_key = "—"
2072
+ _signal_score = 0
2073
+ _signal_reasons = []
2074
+ _signal_confidence = min(0.82, 0.46 + abs(_signal_score) * 0.07) if _enough_data else 0.0
2075
+
2076
+ signal = _sig_key
2077
+ signal_str = _SIGNAL_LABELS["zh"][_sig_key] # will be overwritten after _lang is resolved below
2078
+
2079
+ # ── Price-action analysis (available even without TA) ─────────────────
2080
+ _range_size = (high - low) if (high is not None and low is not None) else None
2081
+ _price_pos = int((price - low) / _range_size * 100) if _range_size and _range_size > 0 else None
2082
+ _swing_pct = round(_range_size / price * 100, 2) if _range_size and price else None
2083
+ _chg_abs = abs(chg) if chg is not None else None
2084
+ _pa_lines = []
2085
+ if _price_pos is not None:
2086
+ _pos_label = "日内高位" if _price_pos >= 70 else ("日内低位" if _price_pos <= 30 else "日内中段")
2087
+ _pa_lines.append(f"价格位置:{_pos_label}(日内第 {_price_pos} 百分位)")
2088
+ if _swing_pct:
2089
+ _pa_lines.append(f"日内振幅:{_swing_pct:.1f}%{'(波动偏大)' if _swing_pct > 3 else ''}")
2090
+ if chg is not None and _chg_abs is not None:
2091
+ if _chg_abs < 0.01:
2092
+ _pa_lines.append("今日动能:持平")
2093
+ else:
2094
+ _mo = "上涨" if chg > 0 else "下跌"
2095
+ _strength = "(大幅)" if _chg_abs > 2 else ("(温和)" if _chg_abs < 0.5 else "")
2096
+ _pa_lines.append(f"今日动能:{_mo} {_chg_abs:.2f}%{_strength}")
2097
+
2098
+ # ── Build output ──────────────────────────────────────────────────────
2099
+ weekday = datetime.now().weekday()
2100
+ ti_provider = ti.get("provider", "")
2101
+ quote_chain = quote.get("provider_chain") or [provider]
2102
+ data_src = " -> ".join(str(p) for p in quote_chain if p)
2103
+ if ti_provider and ti_provider not in quote_chain:
2104
+ data_src += f" + {ti_provider}"
2105
+ # volume == 0 means "not reported" here (finnhub /quote has no volume),
2106
+ # not "zero trading" — treat as unknown so we hide the row vs showing "0".
2107
+ _vol_str = _fmt_int(volume) if volume else "N/A"
2108
+ _now_str = datetime.now().strftime("%Y-%m-%d")
2109
+
2110
+ # ── Language-aware labels ─────────────────────────────────────────────
2111
+ _lang = _detect_lang(message)
2112
+ _en = _lang == "en"
2113
+ signal_str = _SIGNAL_LABELS.get(_lang, _SIGNAL_LABELS["zh"])[_sig_key]
2114
+ _L = {
2115
+ "disclaimer": "Not investment advice" if _en else "不构成投资建议",
2116
+ "after_hours": "After-hours" if _en else "休市/盘后",
2117
+ "market_open": "Market open" if _en else "盘中",
2118
+ "price_hdr": "Metric" if _en else "指标",
2119
+ "value_hdr": "Value" if _en else "数值",
2120
+ "latest": "Last price" if _en else "最新价",
2121
+ "day_range": "Day range" if _en else "日内区间",
2122
+ "swing": "Swing" if _en else "振幅",
2123
+ "mktcap": "Mkt cap" if _en else "市值",
2124
+ "volume": "Volume" if _en else "成交量",
2125
+ "ta_hdr": "Indicator" if _en else "技术指标",
2126
+ "meaning_hdr": "Meaning" if _en else "含义",
2127
+ "overbought": "Overbought" if _en else "超买",
2128
+ "oversold": "Oversold" if _en else "超卖",
2129
+ "neutral": "Neutral" if _en else "中性",
2130
+ "bull_mom": "Bullish momentum" if _en else "多头动能",
2131
+ "bear_mom": "Bearish momentum" if _en else "空头动能",
2132
+ "above_ma20": "Above MA20 ↑" if _en else "价格高于MA20 ↑",
2133
+ "below_ma20": "Below MA20 ↓" if _en else "价格低于MA20 ↓",
2134
+ "above_ma60": "Above MA60 ↑" if _en else "价格高于MA60 ↑",
2135
+ "below_ma60": "Below MA60 ↓" if _en else "价格低于MA60 ↓",
2136
+ "support": "Support" if _en else "支撑位",
2137
+ "resistance": "Resistance" if _en else "阻力位",
2138
+ "signal_lbl": "**Signal**" if _en else "**信号**",
2139
+ "pa_hdr": "**Price action** (TA indicators unavailable)" if _en else "**价格行动分析**(仅基于价格,TA 指标暂不可用)",
2140
+ "sig_no_ta": "TA indicators unavailable, price action above for reference" if _en else "技术指标暂缺,以上价格行动供参考",
2141
+ "ta_unavail": (f"*TA data unavailable — retry later or run `/ta {symbol}`*") if _en
2142
+ else f"*TA 数据暂时不可用,稍后重试或运行 `/ta {symbol}`*",
2143
+ "ta_hint_fh": (f"*Enable full TA*: set a free Finnhub key → `/apikey set finnhub <KEY>`"
2144
+ f" ([finnhub.io](https://finnhub.io/register))") if _en else
2145
+ (f"*启用完整 TA*:配置免费 Finnhub key → `/apikey set finnhub <KEY>`"
2146
+ f" ([注册](https://finnhub.io/register))"),
2147
+ "data_status": "**Data status**" if _en else "**数据状态**",
2148
+ "stale_warn": "Data may be stale, please retry later" if _en else "数据可能已过期,请稍后重试",
2149
+ "missing": "Missing fields" if _en else "缺少字段",
2150
+ "rate_warn": "Data source rate-limited, will auto-retry" if _en else "数据源请求频率受限,稍后自动重试",
2151
+ "timeout_warn": "Data source request timed out" if _en else "数据源请求超时",
2152
+ "nodata_warn": "No data available for this symbol" if _en else "该标的暂无数据",
2153
+ "next_hdr": "**Next steps**" if _en else "**下一步**",
2154
+ "team_desc": "Deep analysis (fundamental + technical)" if _en else "深度分析(基本面 + 技术面)",
2155
+ "ta_desc": "Open full technical chart" if _en else "打开完整技术图表",
2156
+ "report_desc": "Generate institutional research report" if _en else "生成机构级研究报告",
2157
+ "backtest_desc":"Backtest 1y momentum strategy" if _en else "回测 1 年动量策略",
2158
+ "pos_high": "Upper range" if _en else "日内高位",
2159
+ "pos_low": "Lower range" if _en else "日内低位",
2160
+ "pos_mid": "Mid range" if _en else "日内中段",
2161
+ "pos_pct": "day percentile" if _en else "百分位",
2162
+ "swing_high": "(high volatility)" if _en else "(波动偏大)",
2163
+ "flat": "Flat" if _en else "持平",
2164
+ "rising": "Up" if _en else "上涨",
2165
+ "falling": "Down" if _en else "下跌",
2166
+ "strong": " (sharp)" if _en else "(大幅)",
2167
+ "mild": " (mild)" if _en else "(温和)",
2168
+ "day_momentum": "Momentum" if _en else "今日动能",
2169
+ "day_pos": "Price position" if _en else "价格位置",
2170
+ "day_swing": "Day swing" if _en else "日内振幅",
2171
+ }
2172
+
2173
+ session_note = _L["after_hours"] if weekday >= 5 else _L["market_open"]
2174
+
2175
+ def _money(v: float | int | None) -> str:
2176
+ try:
2177
+ return f"{currency} {float(v):,.2f}"
2178
+ except Exception:
2179
+ return "—"
2180
+
2181
+ if _enough_data:
2182
+ if _sig_key in ("STRONG_BUY", "BUY", "HOLD+"):
2183
+ _bias_label = "bullish" if _en else "偏强"
2184
+ elif _sig_key in ("STRONG_SELL", "SELL", "HOLD−"):
2185
+ _bias_label = "bearish" if _en else "偏弱"
2186
+ else:
2187
+ _bias_label = "range-bound" if _en else "震荡"
2188
+ else:
2189
+ _bias_label = "insufficient TA data" if _en else "指标不足"
2190
+
2191
+ _trend_bits = []
2192
+ if ma20 is not None:
2193
+ _trend_bits.append(("above MA20" if price > ma20 else "below MA20") if _en else ("站上 MA20" if price > ma20 else "低于 MA20"))
2194
+ if ma60 is not None:
2195
+ _trend_bits.append(("above MA60" if price > ma60 else "below MA60") if _en else ("站上 MA60" if price > ma60 else "低于 MA60"))
2196
+ if mhist is not None:
2197
+ _trend_bits.append(("MACD positive" if mhist > 0 else "MACD negative") if _en else ("MACD 偏多" if mhist > 0 else "MACD 偏空"))
2198
+ if rsi is not None:
2199
+ if rsi >= 70:
2200
+ _trend_bits.append("RSI overbought" if _en else "RSI 超买")
2201
+ elif rsi <= 30:
2202
+ _trend_bits.append("RSI oversold" if _en else "RSI 超卖")
2203
+ else:
2204
+ _trend_bits.append(f"RSI {rsi:.1f} neutral" if _en else f"RSI {rsi:.1f} 中性")
2205
+
2206
+ _watch_supports = next((row.get("support") or [] for row in timeframe_levels if row.get("support")), supports)
2207
+ _watch_resistances = next((row.get("resistance") or [] for row in timeframe_levels if row.get("resistance")), resistances)
2208
+ _nearest_support = _watch_supports[0] if _watch_supports else None
2209
+ _nearest_resistance = _watch_resistances[0] if _watch_resistances else None
2210
+ if _en:
2211
+ _summary_line = f"{symbol} is currently {str(_bias_label)}"
2212
+ if _trend_bits:
2213
+ _summary_line += " — " + ", ".join(_trend_bits[:3]) + "."
2214
+ else:
2215
+ _summary_line += "."
2216
+ if _nearest_support is not None and _nearest_resistance is not None:
2217
+ _watch_line = (
2218
+ f"Watch {_money(_nearest_resistance)} for a bullish break; "
2219
+ f"losing {_money(_nearest_support)} raises downside risk."
2220
+ )
2221
+ elif _nearest_resistance is not None:
2222
+ _watch_line = f"Watch {_money(_nearest_resistance)} as the next resistance."
2223
+ elif _nearest_support is not None:
2224
+ _watch_line = f"Watch {_money(_nearest_support)} as the nearest support."
2225
+ else:
2226
+ _watch_line = "No reliable support/resistance from current data."
2227
+ else:
2228
+ _summary_line = f"{symbol} 当前{_bias_label}"
2229
+ if _trend_bits:
2230
+ _summary_line += "," + ",".join(_trend_bits[:3]) + "。"
2231
+ else:
2232
+ _summary_line += "。"
2233
+ if _nearest_support is not None and _nearest_resistance is not None:
2234
+ _watch_line = f"上破 {_money(_nearest_resistance)} 才能转强;跌破 {_money(_nearest_support)} 风险放大。"
2235
+ elif _nearest_resistance is not None:
2236
+ _watch_line = f"重点观察 {_money(_nearest_resistance)} 阻力。"
2237
+ elif _nearest_support is not None:
2238
+ _watch_line = f"重点观察 {_money(_nearest_support)} 支撑。"
2239
+ else:
2240
+ _watch_line = "当前数据不足以给出可靠支撑/阻力。"
2241
+
2242
+ _positive_reasons = []
2243
+ _negative_reasons = []
2244
+ _neutral_reasons = []
2245
+ if ma20 is not None:
2246
+ (_positive_reasons if price > ma20 else _negative_reasons).append("price>MA20" if _en and price > ma20 else "price<MA20" if _en else "价格高于 MA20" if price > ma20 else "价格低于 MA20")
2247
+ if ma60 is not None:
2248
+ (_positive_reasons if price > ma60 else _negative_reasons).append("price>MA60" if _en and price > ma60 else "price<MA60" if _en else "价格高于 MA60" if price > ma60 else "价格低于 MA60")
2249
+ if mhist is not None:
2250
+ (_positive_reasons if mhist > 0 else _negative_reasons).append("MACD positive" if _en and mhist > 0 else "MACD negative" if _en else "MACD 偏多" if mhist > 0 else "MACD 偏空")
2251
+ if rsi is not None:
2252
+ if 30 < rsi < 70:
2253
+ _neutral_reasons.append(f"RSI {rsi:.1f} neutral" if _en else f"RSI {rsi:.1f} 中性")
2254
+ elif rsi <= 30:
2255
+ _positive_reasons.append("RSI oversold" if _en else "RSI 超卖")
2256
+ elif rsi >= 70:
2257
+ _negative_reasons.append("RSI overbought" if _en else "RSI 超买")
2258
+
2259
+ def _join_reasons(items: list[str]) -> str:
2260
+ return ", ".join(items) if _en else "、".join(items)
2261
+
2262
+ lines = []
2263
+ # ── Header ──
2264
+ _header_name = name if name and name.upper() != symbol.upper() else ""
2265
+ if _header_name:
2266
+ lines.append(f"## {_header_name} `{symbol}`")
2267
+ else:
2268
+ lines.append(f"## `{symbol}`")
2269
+ lines.append(f"*{data_src} · {_now_str} · {session_note} · {_L['disclaimer']}*")
2270
+ lines.append("")
2271
+ lines.append(f"**{'Takeaway' if _en else '结论'}**:{_summary_line}")
2272
+ lines.append(f"**{'Watch' if _en else '观察位'}**:{_watch_line}")
2273
+ lines.append("")
2274
+
2275
+ # ── Price table ──
2276
+ _chg_display = chg_str if (chg is not None and abs(chg) >= 0.005) else "—"
2277
+ lines.append(f"| {_L['price_hdr']} | {_L['value_hdr']} |")
2278
+ lines.append("|------|------|")
2279
+ lines.append(f"| {_L['latest']} | **{currency} {price:,.2f}** `{_chg_display}` |")
2280
+ if range_str:
2281
+ swing_cell = f" {_L['swing']} {_swing_pct:.1f}%" if _swing_pct else ""
2282
+ lines.append(f"| {_L['day_range']} | {range_str}{swing_cell} |")
2283
+ if _mktcap_str:
2284
+ lines.append(f"| {_L['mktcap']} | {_mktcap_str} |")
2285
+ if _vol_str != "N/A":
2286
+ lines.append(f"| {_L['volume']} | {_vol_str} |")
2287
+
2288
+ # ── Technical table ──
2289
+ lines.append("")
2290
+ lines.append(f"| {_L['ta_hdr']} | {_L['value_hdr']} | {_L['meaning_hdr']} |")
2291
+ lines.append("|---------|------|------|")
2292
+ if rsi is not None:
2293
+ _rsi_meaning = _L["overbought"] if rsi >= 70 else (_L["oversold"] if rsi <= 30 else _L["neutral"])
2294
+ lines.append(f"| RSI(14) | {rsi_view} | {_rsi_meaning} |")
2295
+ else:
2296
+ lines.append("| RSI(14) | — | — |")
2297
+ if mhist is not None:
2298
+ _macd_meaning = _L["bull_mom"] if mhist > 0 else _L["bear_mom"]
2299
+ lines.append(f"| MACD hist | {mhist:.4f} | {_macd_meaning} |")
2300
+ else:
2301
+ lines.append("| MACD hist | — | — |")
2302
+ if ma20 is not None:
2303
+ lines.append(f"| MA20 | {currency} {ma20:,.2f} | {_L['above_ma20'] if price > ma20 else _L['below_ma20']} |")
2304
+ if ma60 is not None:
2305
+ lines.append(f"| MA60 | {currency} {ma60:,.2f} | {_L['above_ma60'] if price > ma60 else _L['below_ma60']} |")
2306
+ if support_str:
2307
+ lines.append(f"| {_L['support']} | {support_str} | |")
2308
+ if resistance_str:
2309
+ lines.append(f"| {_L['resistance']} | {resistance_str} | |")
2310
+
2311
+ _append_timeframe_levels(lines, timeframe_levels, currency, english=_en)
2312
+
2313
+ # ── Price-action lines (rebuild with language-aware labels) ──
2314
+ _pa_lines_l10n = []
2315
+ if _price_pos is not None:
2316
+ _pos_label = _L["pos_high"] if _price_pos >= 70 else (_L["pos_low"] if _price_pos <= 30 else _L["pos_mid"])
2317
+ _pa_lines_l10n.append(f"{_L['day_pos']}:{_pos_label}({_en and 'day' or '日内第'} {_price_pos} {_L['pos_pct']})")
2318
+ if _swing_pct:
2319
+ _swing_note = _L["swing_high"] if _swing_pct > 3 else ""
2320
+ _pa_lines_l10n.append(f"{_L['day_swing']}:{_swing_pct:.1f}%{_swing_note}")
2321
+ if chg is not None and _chg_abs is not None:
2322
+ if _chg_abs < 0.01:
2323
+ _pa_lines_l10n.append(f"{_L['day_momentum']}:{_L['flat']}")
2324
+ else:
2325
+ _mo = _L["rising"] if chg > 0 else _L["falling"]
2326
+ _strength = _L["strong"] if _chg_abs > 2 else (_L["mild"] if _chg_abs < 0.5 else "")
2327
+ _pa_lines_l10n.append(f"{_L['day_momentum']}:{_mo} {_chg_abs:.2f}%{_strength}")
2328
+
2329
+ # ── Signal ──
2330
+ lines.append("")
2331
+ if _enough_data:
2332
+ _score_detail = (
2333
+ f" · score {_signal_score:+d} · confidence {_signal_confidence:.0%}"
2334
+ if _en else
2335
+ f" · 量化分 {_signal_score:+d} · 置信度 {_signal_confidence:.0%}"
2336
+ )
2337
+ lines.append(f"{_L['signal_lbl']}:`{signal}` — {signal_str}{_score_detail}")
2338
+ _reason_parts = []
2339
+ if _positive_reasons:
2340
+ _reason_parts.append(("support: " if _en else "支撑:") + _join_reasons(_positive_reasons[:3]))
2341
+ if _negative_reasons:
2342
+ _reason_parts.append(("pressure: " if _en else "压力:") + _join_reasons(_negative_reasons[:3]))
2343
+ if _neutral_reasons:
2344
+ _reason_parts.append(("neutral: " if _en else "中性:") + _join_reasons(_neutral_reasons[:2]))
2345
+ if _reason_parts:
2346
+ lines.append(f"**{'Signal drivers' if _en else '信号拆解'}**:" + ("; ".join(_reason_parts) if _en else ";".join(_reason_parts)))
2347
+ else:
2348
+ lines.append(_L["pa_hdr"])
2349
+ for _pal in _pa_lines_l10n:
2350
+ lines.append(f"- {_pal}")
2351
+ lines.append("")
2352
+ lines.append(f"{_L['signal_lbl']}:`{signal}` — {_L['sig_no_ta']}")
2353
+
2354
+ # ── Config hint (show only when TA missing) ──
2355
+ if not _enough_data:
2356
+ lines.append("")
2357
+ if _is_a_share:
2358
+ _ak_hint = (f"> **Full TA data**: akshare should be available — retry or run `/ta {symbol}`"
2359
+ if _en else
2360
+ f"> **完整 TA 数据**:akshare 应已可用,若持续失败请重试或运行 `/ta {symbol}`")
2361
+ lines.append(_ak_hint)
2362
+ elif not _fh_key:
2363
+ lines.append(_L["ta_hint_fh"])
2364
+ else:
2365
+ lines.append(_L["ta_unavail"])
2366
+
2367
+ # ── Prediction hint when the user explicitly asks for forecast/outlook ──
2368
+ if any(k in message.lower() for k in ("预测", "预判", "forecast", "predict", "prediction", "outlook")):
2369
+ _prediction_added = False
2370
+
2371
+ def _append_rule_prediction() -> None:
2372
+ nonlocal _prediction_added
2373
+ if _prediction_added or not _enough_data:
2374
+ return
2375
+ score = 0
2376
+ if ma20 is not None:
2377
+ score += 1 if price > ma20 else -1
2378
+ if ma60 is not None:
2379
+ score += 1 if price > ma60 else -1
2380
+ if mhist is not None:
2381
+ score += 1 if mhist > 0 else -1
2382
+ if rsi is not None:
2383
+ if rsi >= 75:
2384
+ score -= 1
2385
+ elif rsi <= 30:
2386
+ score += 1
2387
+ if _en:
2388
+ direction = "bullish" if score >= 2 else ("bearish" if score <= -2 else "range-bound")
2389
+ confidence = min(0.7, 0.45 + abs(score) * 0.06)
2390
+ lines.append("")
2391
+ lines.append("### Forecast reference")
2392
+ lines.append(f"- Rule-based direction: `{direction}` · confidence: {confidence:.0%}")
2393
+ lines.append("- Prediction model did not return this symbol; this is inferred only from RSI, MACD, and moving averages.")
2394
+ else:
2395
+ direction = "偏强" if score >= 2 else ("偏弱" if score <= -2 else "震荡")
2396
+ confidence = min(0.7, 0.45 + abs(score) * 0.06)
2397
+ lines.append("")
2398
+ lines.append("### 预测参考")
2399
+ lines.append(f"- 规则型方向:`{direction}` · 置信度:{confidence:.0%}")
2400
+ lines.append("- 预测工具当前未返回该标的数据,以上仅由 RSI、MACD 和均线结构推导。")
2401
+ _prediction_added = True
2402
+
2403
+ try:
2404
+ pred_symbol = symbol
2405
+ if _is_a_share and _ashare_code:
2406
+ pred_symbol = ("sh" if _ashare_code.startswith(("6", "9")) else "sz") + _ashare_code
2407
+ from local_finance_tools import _get_predictions
2408
+ import contextlib as _ctxlib_pred
2409
+ import io as _io_pred
2410
+ with _ctxlib_pred.redirect_stdout(_io_pred.StringIO()), _ctxlib_pred.redirect_stderr(_io_pred.StringIO()):
2411
+ pred = _get_predictions({"symbols": [pred_symbol], "prediction_days": 5})
2412
+ preds = pred.get("predictions") or []
2413
+ if preds:
2414
+ p0 = preds[0]
2415
+ direction = str(p0.get("direction") or p0.get("signal") or "neutral")
2416
+ ret = p0.get("predicted_return")
2417
+ conf = p0.get("confidence")
2418
+ lines.append("")
2419
+ lines.append("### 预测参考")
2420
+ bits = [f"方向:`{direction}`"]
2421
+ try:
2422
+ bits.append(f"5日预期收益:{float(ret):+.2%}")
2423
+ except Exception:
2424
+ pass
2425
+ try:
2426
+ bits.append(f"置信度:{float(conf):.0%}")
2427
+ except Exception:
2428
+ pass
2429
+ lines.append("- " + " · ".join(bits))
2430
+ lines.append("- 该预测由本地动量/技术因子模型生成,只作为风险参考,不构成投资建议。")
2431
+ _prediction_added = True
2432
+ elif _enough_data:
2433
+ _append_rule_prediction()
2434
+ except Exception:
2435
+ _append_rule_prediction()
2436
+
2437
+ # ── Data quality — only show when actionable ──
2438
+ _quality_missing = _snapshot_quality.get("missing_fields") or []
2439
+ _quality_warnings = _snapshot_quality.get("warnings") or []
2440
+ _quality_errors = _snapshot_quality.get("errors") or []
2441
+ _quality_status = _snapshot_quality.get("status", "")
2442
+ _show_quality = _quality_status in ("unavailable", "partial", "stale") or bool(_quality_missing)
2443
+ if _snapshot_quality and _show_quality:
2444
+ lines.append("")
2445
+ lines.append(_L["data_status"])
2446
+ if _snapshot_quality.get("stale"):
2447
+ lines.append(f"- {_L['stale_warn']}")
2448
+ if _quality_missing:
2449
+ _missing_map = {"price": "price" if _en else "价格",
2450
+ "volume": "volume" if _en else "成交量",
2451
+ "change": "change %" if _en else "涨跌幅"}
2452
+ # Suppress "price" from missing list when we actually have a price to display —
2453
+ # it means the primary source (yfinance) failed but a fallback (Finnhub) succeeded.
2454
+ _filtered_missing = [
2455
+ f for f in _quality_missing
2456
+ if not (f == "price" and price is not None and price > 0)
2457
+ ]
2458
+ _missing_labels = [_missing_map.get(f, f) for f in _filtered_missing]
2459
+ if _missing_labels:
2460
+ lines.append(f"- {_L['missing']}: {', '.join(_missing_labels)}")
2461
+ _user_warnings = []
2462
+ for w in (_quality_warnings + _quality_errors)[:2]:
2463
+ _w = str(w)
2464
+ if "rate" in _w.lower() or "429" in _w.lower() or "too many" in _w.lower():
2465
+ _user_warnings.append(_L["rate_warn"])
2466
+ elif "timeout" in _w.lower():
2467
+ _user_warnings.append(_L["timeout_warn"])
2468
+ elif "not found" in _w.lower() or "no data" in _w.lower():
2469
+ _user_warnings.append(_L["nodata_warn"])
2470
+ for _uw in dict.fromkeys(_user_warnings):
2471
+ lines.append(f"- {_uw}")
2472
+
2473
+ # ── Next actions ──
2474
+ lines.append("")
2475
+ lines.append(_L["next_hdr"])
2476
+ lines.append(f"- `/team {symbol}` — {_L['team_desc']}")
2477
+ lines.append(f"- `/ta {symbol}` — {_L['ta_desc']}")
2478
+ if _is_a_share:
2479
+ lines.append(f"- `/report {symbol}` — {_L['report_desc']}")
2480
+ else:
2481
+ lines.append(f"- `/backtest momentum {symbol} --period 1y` — {_L['backtest_desc']}")
2482
+
2483
+ return {
2484
+ "success": True,
2485
+ "response": "\n".join(lines),
2486
+ "tools_used": ["market_snapshot"],
2487
+ "analysis_complete": True,
2488
+ "symbol": symbol,
2489
+ "price": price,
2490
+ "change_pct": chg,
2491
+ "currency": currency,
2492
+ "name": name,
2493
+ "signal": signal,
2494
+ "signal_score": _signal_score,
2495
+ "signal_confidence": _signal_confidence,
2496
+ "rsi": rsi,
2497
+ "macd_hist": mhist,
2498
+ "ma20": ma20,
2499
+ "ma60": ma60,
2500
+ "bb_upper": bbu,
2501
+ "bb_lower": bbl,
2502
+ "support": support_str,
2503
+ "resistance": resistance_str,
2504
+ "supports": supports,
2505
+ "resistances": resistances,
2506
+ "timeframe_levels": timeframe_levels,
2507
+ "data_src": data_src,
2508
+ "as_of": _now_str,
2509
+ }