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
market_data_client.py ADDED
@@ -0,0 +1,1899 @@
1
+ """
2
+ market_data_client.py — Arthera unified real-time market data client.
3
+
4
+ Design principles
5
+ ─────────────────
6
+ 1. Proxy-bypassed : uses requests.Session(trust_env=False) so Chinese data
7
+ sources work even when HTTP_PROXY / HTTPS_PROXY is set (VPN / Clash).
8
+ 2. Multi-source fallback chain:
9
+ US/Global → yfinance (primary) → Alpha Vantage (if key set)
10
+ A-shares → Eastmoney push2 API → AKShare (historical)
11
+ Crypto → ccxt (binance/okx) → yfinance fallback
12
+ 3. Unified output schema — every function returns a consistent dict so callers
13
+ don't care which data source actually served the data.
14
+ 4. No blocking calls inside async context — use run_in_executor where needed.
15
+
16
+ Quick usage
17
+ ───────────
18
+ from market_data_client import MarketDataClient
19
+ mdc = MarketDataClient()
20
+ print(mdc.quote("NVDA"))
21
+ print(mdc.quote("000001")) # A-share
22
+ print(mdc.quote("BTC/USDT")) # crypto
23
+ print(mdc.history("AAPL", days=30))
24
+ print(mdc.indices()) # major global indices
25
+ print(mdc.northbound_flow()) # 北向资金
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import os
33
+ import threading
34
+ import time
35
+ from datetime import datetime, timedelta, timezone
36
+ from pathlib import Path
37
+ from typing import Any, Dict, List, Optional
38
+
39
+ import numpy as np
40
+ import pandas as pd
41
+ import requests
42
+
43
+ logger = logging.getLogger(__name__)
44
+ logging.getLogger("yfinance").setLevel(logging.CRITICAL)
45
+ logging.getLogger("curl_cffi").setLevel(logging.CRITICAL)
46
+
47
+ _UNSET = object() # sentinel: "not yet resolved" (distinct from a resolved None)
48
+
49
+ # 东方财富 API 公开默认令牌(非个人凭证,各开源项目通用值)
50
+ # 可通过环境变量覆盖:export EASTMONEY_UT=your_token
51
+ _EM_UT = os.environ.get("EASTMONEY_UT", "bd1d9ddb04089700cf9c27f6f7426281")
52
+
53
+
54
+ def _friendly_market_error(symbol: str, providers: List[str], detail: Any = "") -> str:
55
+ """Return a user-facing market data error without leaking vendor internals."""
56
+ tried = " -> ".join(providers) if providers else "market data providers"
57
+ detail_text = str(detail).lower()
58
+ if any(token in detail_text for token in ("timeout", "timed out", "curl: (28)", "read timed out")):
59
+ reason = "连接超时"
60
+ elif any(token in detail_text for token in ("connection", "network", "remote", "refused")):
61
+ reason = "网络连接不可用"
62
+ elif any(token in detail_text for token in ("rate", "429", "too many")):
63
+ reason = "数据源限流"
64
+ else:
65
+ reason = "数据源暂时不可用"
66
+ return f"{reason},已尝试 {tried},暂时无法获取 {symbol} 行情。请稍后重试或切换数据源。"
67
+
68
+
69
+ def _is_valid_price(value: Any) -> bool:
70
+ try:
71
+ return float(value) > 0
72
+ except (TypeError, ValueError):
73
+ return False
74
+
75
+ # ── Simple in-process cache (TTL-based) ─────────────────────────────────────
76
+
77
+ class _Cache:
78
+ def __init__(self):
79
+ self._store: Dict[str, tuple] = {} # key → (value, expire_ts)
80
+ self._lock = threading.Lock()
81
+
82
+ def get(self, key: str):
83
+ with self._lock:
84
+ entry = self._store.get(key)
85
+ if entry and time.time() < entry[1]:
86
+ return entry[0]
87
+ return None
88
+
89
+ def set(self, key: str, value, ttl: int = 60):
90
+ with self._lock:
91
+ self._store[key] = (value, time.time() + ttl)
92
+
93
+ _cache = _Cache()
94
+
95
+
96
+ def _session() -> requests.Session:
97
+ """Return a requests Session for Chinese financial APIs.
98
+
99
+ Uses the system proxy (HTTP_PROXY / HTTPS_PROXY) when set — users outside
100
+ China need a proxy/VPN to reach Eastmoney servers. Previously trust_env=False
101
+ was set here, which bypassed the proxy and caused connection failures for
102
+ non-China IPs.
103
+ """
104
+ s = requests.Session()
105
+ s.headers.update({
106
+ "User-Agent": (
107
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
108
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
109
+ "Chrome/124.0 Safari/537.36"
110
+ ),
111
+ "Referer": "https://finance.eastmoney.com/",
112
+ })
113
+ return s
114
+
115
+
116
+ def _session_no_proxy() -> requests.Session:
117
+ """Return a requests Session that explicitly bypasses any system proxy.
118
+
119
+ Use for globally-accessible endpoints (Yahoo Finance, Alpha Vantage) that
120
+ should NOT go through a China-routing VPN.
121
+ """
122
+ s = requests.Session()
123
+ s.trust_env = False
124
+ s.headers.update({
125
+ "User-Agent": (
126
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
127
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
128
+ "Chrome/124.0 Safari/537.36"
129
+ ),
130
+ })
131
+ return s
132
+
133
+
134
+ # ── Symbol classification ────────────────────────────────────────────────────
135
+
136
+ def _is_ashare(symbol: str) -> bool:
137
+ s = symbol.strip().upper()
138
+ if s.endswith((".SZ", ".SS", ".SH")):
139
+ s = s.rsplit(".", 1)[0]
140
+ digits = s.lstrip("SZ").lstrip("SH")
141
+ return (
142
+ (s.startswith(("60","00","30","68","83","87")) and s.isdigit() and len(s) == 6)
143
+ or (s.startswith(("SH", "SZ")) and s[2:].isdigit() and len(s[2:]) == 6)
144
+ or digits.isdigit() and len(digits) == 6
145
+ )
146
+
147
+ def _is_crypto(symbol: str) -> bool:
148
+ s = symbol.upper()
149
+ return "/" in s or s.endswith(("USDT","BTC","ETH","BNB","-USD","-USDT"))
150
+
151
+ def _norm_crypto(symbol: str, quote: str = "USDT") -> str:
152
+ """Normalise a crypto symbol to ccxt 'BASE/QUOTE' form.
153
+
154
+ Fixes the rstrip bug: 'DOTUSDT'.rstrip('USDT') → 'DO' (strips chars, not
155
+ the suffix). Here we strip the quote suffix exactly.
156
+ """
157
+ s = symbol.upper().strip()
158
+ if "/" in s:
159
+ return s
160
+ if "-" in s:
161
+ base, quote_part = s.split("-", 1)
162
+ quote_norm = "USDT" if quote_part == "USD" else quote_part
163
+ return f"{base}/{quote_norm}"
164
+ for q in ("USDT", "USDC", "BUSD", "USD", "BTC", "ETH", "BNB"):
165
+ if s.endswith(q) and len(s) > len(q):
166
+ return f"{s[:-len(q)]}/{q if q != 'USD' else 'USDT'}"
167
+ return f"{s}/{quote}"
168
+
169
+ def _normalise_ashare(symbol: str) -> str:
170
+ s = symbol.strip().upper()
171
+ if s.endswith((".SZ", ".SS", ".SH")):
172
+ s = s.rsplit(".", 1)[0]
173
+ s = s.lstrip("SH").lstrip("SZ")
174
+ s = s.lstrip("0") if s.startswith(("60","00","30","68","83","87")) else s
175
+ return s.zfill(6)
176
+
177
+ def _ashare_secid(code: str) -> str:
178
+ """Convert 6-digit code → Eastmoney secid (1.XXXXXX or 0.XXXXXX)."""
179
+ code = code.zfill(6)
180
+ if code.startswith(("60", "68", "83", "87")):
181
+ return f"1.{code}" # 上交所
182
+ return f"0.{code}" # 深交所
183
+
184
+
185
+ # ═══════════════════════════════════════════════════════════════════════════
186
+ # MarketDataClient
187
+ # ═══════════════════════════════════════════════════════════════════════════
188
+
189
+ class MarketDataClient:
190
+ """Unified market data access with proxy bypass and multi-source fallback."""
191
+
192
+ EM_QUOTE_URL = "https://push2.eastmoney.com/api/qt/stock/get"
193
+ EM_ULIST_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
194
+ EM_NORTHBOUND = "https://push2.eastmoney.com/api/qt/kamt/get"
195
+ EM_HIST_URL = "https://push2.eastmoney.com/api/qt/stock/kline/get"
196
+ EM_HOT_URL = "https://push2.eastmoney.com/api/qt/clist/get"
197
+ EM_LIMIT_URL = "https://push2.eastmoney.com/api/qt/clist/get"
198
+
199
+ # Eastmoney field map for stock quote
200
+ _EM_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f169,f170,f171,f116,f117,f162,f167,f168"
201
+
202
+ def __init__(self, alpha_vantage_key: str = ""):
203
+ self._sess = _session()
204
+ self._sess_np = None # lazy no-proxy session for proxy-bypass fallback
205
+ self._av_key = alpha_vantage_key or os.getenv("ALPHA_VANTAGE_KEY", "")
206
+ self._fh_key = self._load_finnhub_key()
207
+ self._ts_source = _UNSET # lazy Tushare source (None once resolved & unconfigured)
208
+
209
+ def _tushare(self):
210
+ """Lazily resolve the user's configured Tushare source.
211
+
212
+ Returns a ``TushareSource`` only when TUSHARE_TOKEN is set (env or
213
+ ~/.aria|.arthera/.env); otherwise ``None``. Users who never configured
214
+ Tushare pay nothing — the A-share chain falls straight through to the
215
+ free HTTP sources. When a token *is* present we honour it as the
216
+ preferred A-share source (it is the user's explicit, reliable choice,
217
+ especially behind the GFW where Eastmoney/AKShare can be flaky).
218
+ """
219
+ if self._ts_source is _UNSET:
220
+ try:
221
+ from datasources.sources.tushare_source import TushareSource
222
+ src = TushareSource()
223
+ self._ts_source = src if src.is_configured() else None
224
+ except Exception as e:
225
+ logger.debug("Tushare source unavailable: %s", e)
226
+ self._ts_source = None
227
+ return self._ts_source
228
+
229
+ def _em_get_json(self, url: str, params: dict, timeout: int = 8):
230
+ """GET JSON from an eastmoney endpoint, resilient to flaky hosts/proxy.
231
+
232
+ Two failure modes are handled together:
233
+ * A broken HTTP(S)_PROXY returns empty bodies / ProxyError → retry
234
+ with a trust_env=False (no-proxy) session.
235
+ * eastmoney's push2 cluster has many numbered hosts
236
+ (N.push2.eastmoney.com) that go down individually → rotate hosts
237
+ until one responds with valid JSON.
238
+ """
239
+ import re as _re
240
+ # Build a small candidate host list (original + a couple numbered hosts).
241
+ # Kept short so we fail fast and don't hammer eastmoney's rate limiter.
242
+ m = _re.search(r"https?://([^/]+)(/.*)", url)
243
+ if m and "push2.eastmoney.com" in m.group(1):
244
+ path = m.group(2)
245
+ _hosts = list(dict.fromkeys([m.group(1), "1.push2.eastmoney.com", "7.push2.eastmoney.com"]))
246
+ _urls = [f"https://{h}{path}" for h in _hosts]
247
+ else:
248
+ _urls = [url]
249
+
250
+ if self._sess_np is None:
251
+ self._sess_np = _session_no_proxy()
252
+ for candidate in _urls:
253
+ for sess in (self._sess, self._sess_np):
254
+ try:
255
+ r = sess.get(candidate, params=params, timeout=timeout)
256
+ r.raise_for_status()
257
+ data = r.json()
258
+ if isinstance(data, dict) and data.get("data") is not None:
259
+ return data
260
+ except Exception:
261
+ continue
262
+ return None
263
+
264
+ @staticmethod
265
+ def _load_finnhub_key() -> str:
266
+ """Read Finnhub API key from env var or ~/.arthera/providers.json."""
267
+ key = os.getenv("FINNHUB_API_KEY", "") or os.getenv("FINNHUB_KEY", "")
268
+ if key:
269
+ return key
270
+ try:
271
+ p = Path.home() / ".arthera" / "providers.json"
272
+ if p.exists():
273
+ raw = json.loads(p.read_text(encoding="utf-8"))
274
+ key = raw.get("data", {}).get("finnhub", {}).get("api_key", "")
275
+ if key:
276
+ return key
277
+ except Exception:
278
+ pass
279
+ return ""
280
+
281
+ def _quote_finnhub(self, symbol: str) -> Dict[str, Any]:
282
+ """Finnhub quote fallback — uses configured API key."""
283
+ if not self._fh_key:
284
+ return {"success": False, "error": "no finnhub key", "symbol": symbol}
285
+ try:
286
+ url = f"https://finnhub.io/api/v1/quote?symbol={symbol.upper()}&token={self._fh_key}"
287
+ r = self._sess.get(url, timeout=6)
288
+ if r.status_code != 200:
289
+ return {"success": False, "error": f"HTTP {r.status_code}", "symbol": symbol}
290
+ d = r.json()
291
+ price = float(d.get("c") or 0)
292
+ if price <= 0:
293
+ return {"success": False, "error": "price=0 from finnhub", "symbol": symbol}
294
+ prev = float(d.get("pc") or price)
295
+ chg_p = round(float(d.get("dp") or 0), 2)
296
+ name = symbol
297
+ mktcap = None
298
+ currency = "USD"
299
+ try:
300
+ prof_url = f"https://finnhub.io/api/v1/stock/profile2?symbol={symbol.upper()}&token={self._fh_key}"
301
+ pr = self._sess.get(prof_url, timeout=5).json()
302
+ name = pr.get("name") or symbol
303
+ mktcap = (float(pr.get("marketCapitalization") or 0) * 1e6) or None
304
+ currency = pr.get("currency") or "USD"
305
+ except Exception:
306
+ pass
307
+ # Finnhub /quote has no volume field — enrich from yfinance fast_info
308
+ # so 成交量 isn't always 0 for US stocks (finnhub is primary here).
309
+ _vol = int(d.get("v") or 0)
310
+ if _vol == 0:
311
+ try:
312
+ import yfinance as _yf
313
+ _fi = _yf.Ticker(symbol).fast_info
314
+ _vol = int(getattr(_fi, "last_volume", 0)
315
+ or getattr(_fi, "ten_day_average_volume", 0) or 0)
316
+ except Exception:
317
+ _vol = 0
318
+ return {
319
+ "success": True,
320
+ "symbol": symbol.upper(),
321
+ "name": name,
322
+ "price": price,
323
+ "change": round(price - prev, 4),
324
+ "change_pct": chg_p,
325
+ "volume": _vol,
326
+ "market_cap": mktcap,
327
+ "high": round(float(d.get("h") or 0), 2),
328
+ "low": round(float(d.get("l") or 0), 2),
329
+ "open": round(float(d.get("o") or 0), 4),
330
+ "prev_close": round(prev, 4),
331
+ "currency": currency,
332
+ "market": "US",
333
+ "provider": "finnhub",
334
+ "timestamp": datetime.now().isoformat(),
335
+ }
336
+ except Exception as e:
337
+ return {"success": False, "error": str(e), "symbol": symbol}
338
+
339
+ def _history_finnhub(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
340
+ """Finnhub candle history fallback."""
341
+ if not self._fh_key:
342
+ return {"success": False, "error": "no finnhub key", "symbol": symbol}
343
+ resolution = "D" if interval in ("1d", "day", "daily") else "60"
344
+ _end = int(time.time())
345
+ _start = int((datetime.now() - timedelta(days=days + 5)).timestamp())
346
+ try:
347
+ url = (f"https://finnhub.io/api/v1/stock/candle?symbol={symbol.upper()}"
348
+ f"&resolution={resolution}&from={_start}&to={_end}&token={self._fh_key}")
349
+ r = self._sess.get(url, timeout=10)
350
+ if r.status_code != 200:
351
+ return {"success": False, "error": f"HTTP {r.status_code}", "symbol": symbol}
352
+ d = r.json()
353
+ if d.get("s") != "ok" or not d.get("c"):
354
+ return {"success": False, "error": "no candle data", "symbol": symbol}
355
+ records = [
356
+ {
357
+ "date": datetime.fromtimestamp(t, tz=timezone.utc).strftime("%Y-%m-%d"),
358
+ "open": round(float(o), 4),
359
+ "high": round(float(h), 4),
360
+ "low": round(float(l), 4),
361
+ "close": round(float(c), 4),
362
+ "volume": int(v),
363
+ }
364
+ for t, o, h, l, c, v in zip(
365
+ d["t"], d["o"], d["h"], d["l"], d["c"], d.get("v", [0]*len(d["c"]))
366
+ )
367
+ ]
368
+ return {
369
+ "success": True, "symbol": symbol.upper(),
370
+ "data": records, "provider": "finnhub",
371
+ "interval": interval, "count": len(records),
372
+ }
373
+ except Exception as e:
374
+ return {"success": False, "error": str(e), "symbol": symbol}
375
+
376
+ # ── Public API ───────────────────────────────────────────────────────────
377
+
378
+ def quote(self, symbol: str) -> Dict[str, Any]:
379
+ """Real-time quote for US stock / A-share / crypto / index.
380
+
381
+ Returns unified dict:
382
+ symbol, name, price, change, change_pct, volume, market_cap,
383
+ high, low, open, prev_close, provider, timestamp
384
+ """
385
+ ckey = f"quote:{symbol}"
386
+ cached = _cache.get(ckey)
387
+ if cached:
388
+ return cached
389
+
390
+ if _is_ashare(symbol):
391
+ result = self._quote_ashare(symbol)
392
+ elif _is_crypto(symbol):
393
+ result = self._quote_crypto(symbol)
394
+ elif self._fh_key:
395
+ # Finnhub is primary for US/global stocks — faster, no rate limits
396
+ result = self._quote_finnhub(symbol)
397
+ if not result.get("success"):
398
+ result = self._quote_yfinance(symbol)
399
+ else:
400
+ result = self._quote_yfinance(symbol)
401
+
402
+ if result.get("success"):
403
+ _cache.set(ckey, result, ttl=30) # 30s cache for quotes
404
+ return result
405
+
406
+ def history(self, symbol: str, days: int = 252,
407
+ interval: str = "1d") -> Dict[str, Any]:
408
+ """OHLCV history as a list of dicts (sorted ascending by date).
409
+
410
+ Returns: {success, symbol, data: [{date,open,high,low,close,volume},...],
411
+ provider}
412
+ """
413
+ ckey = f"hist:{symbol}:{days}:{interval}"
414
+ cached = _cache.get(ckey)
415
+ if cached:
416
+ return cached
417
+
418
+ if _is_ashare(symbol):
419
+ result = self._history_ashare(symbol, days, interval)
420
+ elif _is_crypto(symbol):
421
+ result = self._history_crypto(symbol, days, interval)
422
+ else:
423
+ result = self._history_yfinance(symbol, days, interval)
424
+
425
+ if result.get("success"):
426
+ _cache.set(ckey, result, ttl=300) # 5min cache for history
427
+ elif "rate" in str(result.get("error", "")).lower():
428
+ # Brief negative cache: stops every agent in a /team run from
429
+ # re-hammering the same rate-limited symbol with its own backoff.
430
+ _cache.set(ckey, result, ttl=20)
431
+ return result
432
+
433
+ def indices(self) -> Dict[str, Any]:
434
+ """Real-time major global and Chinese indices."""
435
+ ckey = "indices:global"
436
+ cached = _cache.get(ckey)
437
+ if cached:
438
+ return cached
439
+ result = self._fetch_indices()
440
+ if result.get("success"):
441
+ _cache.set(ckey, result, ttl=60)
442
+ return result
443
+
444
+ def northbound_flow(self) -> Dict[str, Any]:
445
+ """北向资金 (沪股通+深股通) net buy/sell today."""
446
+ ckey = "northbound"
447
+ cached = _cache.get(ckey)
448
+ if cached:
449
+ return cached
450
+ result = self._fetch_northbound()
451
+ if result.get("success"):
452
+ _cache.set(ckey, result, ttl=120)
453
+ return result
454
+
455
+ def hot_stocks(self, market: str = "cn", top_n: int = 20) -> Dict[str, Any]:
456
+ """热门/活跃股票榜单."""
457
+ ckey = f"hot:{market}:{top_n}"
458
+ cached = _cache.get(ckey)
459
+ if cached:
460
+ return cached
461
+ if market == "cn":
462
+ result = self._fetch_hot_ashare(top_n)
463
+ else:
464
+ result = self._fetch_hot_us(top_n)
465
+ if result.get("success"):
466
+ _cache.set(ckey, result, ttl=120)
467
+ return result
468
+
469
+ def multi_quote(self, symbols: List[str]) -> Dict[str, Any]:
470
+ """Batch quotes for multiple symbols."""
471
+ results = {}
472
+ for sym in symbols:
473
+ r = self.quote(sym)
474
+ results[sym] = r
475
+ return {"success": True, "quotes": results}
476
+
477
+ def technical_indicators(self, symbol: str, days: int = 120) -> Dict[str, Any]:
478
+ """Compute RSI, MACD, Bollinger Bands, MA from history data."""
479
+ hist = self.history(symbol, days=days)
480
+ if not hist.get("success"):
481
+ return hist
482
+ try:
483
+ df = pd.DataFrame(hist["data"])
484
+ if df.empty:
485
+ return {"success": False, "error": "empty history dataframe", "symbol": symbol}
486
+ df["close"] = pd.to_numeric(df["close"], errors="coerce")
487
+ df["high"] = pd.to_numeric(df.get("high", df["close"]), errors="coerce")
488
+ df["low"] = pd.to_numeric(df.get("low", df["close"]), errors="coerce")
489
+ df.dropna(subset=["close"], inplace=True)
490
+ close = df["close"]
491
+ n = len(close)
492
+ if n < 2:
493
+ return {"success": False, "error": f"insufficient data: {n} bars", "symbol": symbol}
494
+
495
+ result: Dict[str, Any] = {
496
+ "success": True,
497
+ "symbol": symbol,
498
+ "provider": "local_pandas",
499
+ "data_provider": hist.get("provider"),
500
+ "provider_chain": hist.get("provider_chain") or [hist.get("provider", "history")],
501
+ }
502
+
503
+ # Current price (always available if n >= 1)
504
+ result["price"] = round(float(close.iloc[-1]), 4)
505
+
506
+ # RSI(14) — needs at least 15 bars
507
+ if n >= 15:
508
+ delta = close.diff()
509
+ gain = delta.clip(lower=0).rolling(14).mean()
510
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
511
+ rs = gain / loss.replace(0, np.nan)
512
+ rsi_s = 100 - 100 / (1 + rs)
513
+ rsi_v = rsi_s.iloc[-1]
514
+ result["rsi"] = round(float(rsi_v), 2) if not np.isnan(rsi_v) else None
515
+
516
+ # MACD(12,26,9) — needs at least 27 bars
517
+ if n >= 27:
518
+ ema12 = close.ewm(span=12).mean()
519
+ ema26 = close.ewm(span=26).mean()
520
+ macd_l = ema12 - ema26
521
+ sig_l = macd_l.ewm(span=9).mean()
522
+ hist_m = macd_l - sig_l
523
+ result["macd"] = round(float(macd_l.iloc[-1]), 4)
524
+ result["macd_signal"]= round(float(sig_l.iloc[-1]), 4)
525
+ result["macd_hist"] = round(float(hist_m.iloc[-1]), 4)
526
+
527
+ # Bollinger Bands(20) — needs at least 20 bars
528
+ if n >= 20:
529
+ ma20 = close.rolling(20).mean()
530
+ std20 = close.rolling(20).std()
531
+ bb_u = (ma20 + 2 * std20).iloc[-1]
532
+ bb_l = (ma20 - 2 * std20).iloc[-1]
533
+ bb_m = ma20.iloc[-1]
534
+ if not any(np.isnan(v) for v in (bb_u, bb_l, bb_m)):
535
+ result["bb_upper"] = round(float(bb_u), 4)
536
+ result["bb_mid"] = round(float(bb_m), 4)
537
+ result["bb_lower"] = round(float(bb_l), 4)
538
+ result["bb_position"] = round(
539
+ (result["price"] - float(bb_l)) /
540
+ max(float(bb_u - bb_l), 1e-9), 4
541
+ )
542
+
543
+ # Moving averages
544
+ for ma_n in [5, 10, 20, 60, 120]:
545
+ if n >= ma_n:
546
+ v = close.rolling(ma_n).mean().iloc[-1]
547
+ if not np.isnan(v):
548
+ result[f"ma{ma_n}"] = round(float(v), 4)
549
+
550
+ return result
551
+ except Exception as e:
552
+ return {"success": False, "error": str(e), "symbol": symbol}
553
+
554
+ def fundamentals(self, symbol: str) -> Dict[str, Any]:
555
+ """US stock fundamentals via yfinance."""
556
+ if _is_ashare(symbol):
557
+ return self._fundamentals_ashare(symbol)
558
+ try:
559
+ import yfinance as yf
560
+ t = yf.Ticker(symbol)
561
+ info = t.info or {}
562
+ return {
563
+ "success": True,
564
+ "symbol": symbol,
565
+ "name": info.get("longName",""),
566
+ "sector": info.get("sector",""),
567
+ "industry": info.get("industry",""),
568
+ "market_cap": info.get("marketCap"),
569
+ "pe_ratio": info.get("trailingPE"),
570
+ "fwd_pe": info.get("forwardPE"),
571
+ "pb_ratio": info.get("priceToBook"),
572
+ "ps_ratio": info.get("priceToSalesTrailing12Months"),
573
+ "ev_ebitda": info.get("enterpriseToEbitda"),
574
+ # ROE / revenue growth — yfinance returns ratios (0.12), the
575
+ # agent expects percent (12), so ×100. Fixes 基本面 数据不足.
576
+ "roe": (info["returnOnEquity"] * 100
577
+ if info.get("returnOnEquity") is not None else None),
578
+ "revenue_growth": (info["revenueGrowth"] * 100
579
+ if info.get("revenueGrowth") is not None else None),
580
+ "revenue": info.get("totalRevenue"),
581
+ "net_income": info.get("netIncomeToCommon"),
582
+ "eps": info.get("trailingEps"),
583
+ "fwd_eps": info.get("forwardEps"),
584
+ "dividend_yield": info.get("dividendYield"),
585
+ "beta": info.get("beta"),
586
+ "52w_high": info.get("fiftyTwoWeekHigh"),
587
+ "52w_low": info.get("fiftyTwoWeekLow"),
588
+ "analyst_target": info.get("targetMeanPrice"),
589
+ "recommendation": info.get("recommendationKey"),
590
+ "employees": info.get("fullTimeEmployees"),
591
+ "description": (info.get("longBusinessSummary","")[:300]
592
+ if info.get("longBusinessSummary") else ""),
593
+ "provider": "yfinance",
594
+ }
595
+ except Exception as e:
596
+ # Finnhub fundamentals fallback
597
+ if self._fh_key:
598
+ try:
599
+ m_url = (f"https://finnhub.io/api/v1/stock/metric?symbol={symbol.upper()}"
600
+ f"&metric=all&token={self._fh_key}")
601
+ m_r = self._sess.get(m_url, timeout=8)
602
+ if m_r.status_code == 200:
603
+ m = m_r.json().get("metric") or {}
604
+ p_url = (f"https://finnhub.io/api/v1/stock/profile2?symbol={symbol.upper()}"
605
+ f"&token={self._fh_key}")
606
+ p_r = self._sess.get(p_url, timeout=5)
607
+ prof = p_r.json() if p_r.status_code == 200 else {}
608
+ return {
609
+ "success": True,
610
+ "symbol": symbol,
611
+ "name": prof.get("name", symbol),
612
+ "sector": prof.get("gsector", ""),
613
+ "industry": prof.get("gind", ""),
614
+ "market_cap": (float(prof.get("marketCapitalization") or 0) * 1e6) or None,
615
+ "pe_ratio": m.get("peTTM"),
616
+ "fwd_pe": m.get("peExclExtraTTM"),
617
+ "pb_ratio": m.get("pbAnnual") or m.get("pbQuarterly"),
618
+ "ev_ebitda": m.get("currentEv/freeCashFlowAnnual"),
619
+ "dividend_yield": m.get("dividendYieldIndicatedAnnual"),
620
+ # ROE + revenue growth (finnhub metric=all has these) —
621
+ # fixes 基本面 ROE/营收增速 showing 数据不足.
622
+ "roe": m.get("roeTTM") or m.get("roeRfy") or m.get("roeAnnual"),
623
+ "revenue_growth": (m.get("revenueGrowthTTMYoy")
624
+ or m.get("revenueGrowthQuarterlyYoy")
625
+ or m.get("revenueGrowth5Y")),
626
+ "52w_high": m.get("52WeekHigh"),
627
+ "52w_low": m.get("52WeekLow"),
628
+ "beta": m.get("beta"),
629
+ "eps": m.get("epsInclExtraItemsTTM"),
630
+ "provider": "finnhub",
631
+ }
632
+ except Exception:
633
+ pass
634
+ return {"success": False, "error": str(e), "symbol": symbol}
635
+
636
+ # ── US / Global (yfinance) ───────────────────────────────────────────────
637
+
638
+ def _quote_yfinance(self, symbol: str) -> Dict[str, Any]:
639
+ try:
640
+ import yfinance as yf
641
+ except Exception:
642
+ yc = self._quote_yahoo_chart(symbol)
643
+ if yc.get("success"):
644
+ return yc
645
+ stooq = self._quote_stooq(symbol)
646
+ if stooq.get("success"):
647
+ return stooq
648
+ return {"success": False, "error": "yfinance unavailable", "symbol": symbol}
649
+
650
+ def _attempt_fast_info():
651
+ t = yf.Ticker(symbol)
652
+ fi = t.fast_info
653
+ info = {}
654
+ try:
655
+ info = t.info or {}
656
+ except Exception as _e:
657
+ logger.debug("yfinance t.info slow/failed for %s: %s", symbol, _e)
658
+ price = float(fi.last_price or 0)
659
+ prev = float(fi.previous_close or price)
660
+ chg = price - prev
661
+ chg_p = chg / prev * 100 if prev else 0
662
+ return {
663
+ "success": True,
664
+ "symbol": symbol.upper(),
665
+ "name": info.get("longName","") or info.get("shortName",""),
666
+ "price": round(price, 4),
667
+ "change": round(chg, 4),
668
+ "change_pct": round(chg_p, 2),
669
+ "volume": int(fi.three_month_average_volume or 0),
670
+ "market_cap": fi.market_cap,
671
+ "high": round(float(fi.day_high or 0), 2),
672
+ "low": round(float(fi.day_low or 0), 2),
673
+ "open": round(float(fi.open or 0), 4),
674
+ "prev_close": round(prev, 4),
675
+ "currency": fi.currency or "USD",
676
+ "market": "US",
677
+ "provider": "yfinance",
678
+ "timestamp": datetime.now().isoformat(),
679
+ }
680
+
681
+ # Primary attempt
682
+ try:
683
+ return _attempt_fast_info()
684
+ except Exception as e:
685
+ err_text = str(e).lower()
686
+ is_rate_limit = any(t in err_text for t in ("too many", "rate", "429", "429"))
687
+ if not is_rate_limit:
688
+ yc = self._quote_yahoo_chart(symbol)
689
+ if yc.get("success"):
690
+ return yc
691
+ stooq = self._quote_stooq(symbol)
692
+ if stooq.get("success"):
693
+ return stooq
694
+ return {"success": False, "error": str(e), "symbol": symbol}
695
+
696
+ # Rate-limited: wait 3s then retry once
697
+ logger.debug("yfinance rate-limited for %s, retrying in 3s…", symbol)
698
+ time.sleep(3)
699
+ try:
700
+ return _attempt_fast_info()
701
+ except Exception:
702
+ pass
703
+
704
+ # Final fallback: yf.download (different API endpoint, avoids rate limit)
705
+ try:
706
+ from datetime import date as _date, timedelta as _td
707
+ df = yf.download(
708
+ symbol,
709
+ start=(_date.today() - _td(days=5)).isoformat(),
710
+ end=_date.today().isoformat(),
711
+ interval="1d", auto_adjust=True, progress=False, timeout=15,
712
+ )
713
+ if not df.empty:
714
+ if hasattr(df.columns, "levels"):
715
+ df.columns = df.columns.droplevel(1) if len(df.columns.levels) > 1 else df.columns
716
+ last = df.iloc[-1]
717
+ price = round(float(last.get("Close", 0)), 4)
718
+ prev_row = df.iloc[-2] if len(df) >= 2 else last
719
+ prev = round(float(prev_row.get("Close", price)), 4)
720
+ chg = round(price - prev, 4)
721
+ chg_p = round(chg / prev * 100 if prev else 0, 2)
722
+ return {
723
+ "success": True,
724
+ "symbol": symbol.upper(),
725
+ "name": "",
726
+ "price": price,
727
+ "change": chg,
728
+ "change_pct": chg_p,
729
+ "volume": int(last.get("Volume", 0)),
730
+ "market_cap": None,
731
+ "high": round(float(last.get("High", 0)), 2),
732
+ "low": round(float(last.get("Low", 0)), 2),
733
+ "open": round(float(last.get("Open", 0)), 4),
734
+ "prev_close": prev,
735
+ "currency": "USD",
736
+ "market": "US",
737
+ "provider": "yfinance_download",
738
+ "timestamp": datetime.now().isoformat(),
739
+ }
740
+ except Exception as _dl_err:
741
+ logger.debug("yfinance download fallback also failed for %s: %s", symbol, _dl_err)
742
+
743
+ # Finnhub fallback when yfinance is completely exhausted
744
+ if self._fh_key:
745
+ fh = self._quote_finnhub(symbol)
746
+ if fh.get("success"):
747
+ return fh
748
+
749
+ yc = self._quote_yahoo_chart(symbol)
750
+ if yc.get("success"):
751
+ return yc
752
+
753
+ stooq = self._quote_stooq(symbol)
754
+ if stooq.get("success"):
755
+ return stooq
756
+
757
+ return {"success": False, "error": "yfinance rate-limited or no data", "symbol": symbol}
758
+
759
+ @staticmethod
760
+ def _stooq_symbol(symbol: str) -> str:
761
+ """Best-effort conversion from Yahoo-style tickers to Stooq tickers."""
762
+ s = (symbol or "").strip().lower()
763
+ if not s:
764
+ return s
765
+ if s.startswith("^") or "=" in s:
766
+ return ""
767
+ if "." not in s:
768
+ return f"{s}.us"
769
+ suffix_map = {
770
+ "de": "de",
771
+ "pa": "fr",
772
+ "as": "nl",
773
+ "mi": "it",
774
+ "mc": "es",
775
+ "ls": "pt",
776
+ "sw": "ch",
777
+ "l": "uk",
778
+ "hk": "hk",
779
+ }
780
+ base, suffix = s.rsplit(".", 1)
781
+ return f"{base}.{suffix_map.get(suffix, suffix)}"
782
+
783
+ def _history_stooq(self, symbol: str, days: int, interval: str = "1d") -> Dict[str, Any]:
784
+ if interval not in ("1d", "day", "daily"):
785
+ return {"success": False, "error": "stooq only supports daily history", "symbol": symbol}
786
+ stooq_symbol = self._stooq_symbol(symbol)
787
+ if not stooq_symbol:
788
+ return {"success": False, "error": "unsupported stooq symbol", "symbol": symbol}
789
+ try:
790
+ r = self._sess.get(
791
+ "https://stooq.com/q/d/l/",
792
+ params={"s": stooq_symbol, "i": "d"},
793
+ headers={"User-Agent": "Mozilla/5.0"},
794
+ timeout=10,
795
+ )
796
+ text = getattr(r, "text", "")
797
+ if not text and hasattr(r, "content"):
798
+ text = r.content.decode("utf-8", errors="ignore")
799
+ if not text or "No data" in text:
800
+ raise ValueError("empty Stooq response")
801
+ from io import StringIO
802
+ df = pd.read_csv(StringIO(text))
803
+ if df.empty or "Close" not in df.columns:
804
+ raise ValueError("empty Stooq dataframe")
805
+ df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
806
+ df = df.dropna(subset=["Date", "Close"]).sort_values("Date").tail(days + 5)
807
+ records = []
808
+ for _, row in df.iterrows():
809
+ records.append({
810
+ "date": str(row["Date"].date()),
811
+ "open": round(float(row.get("Open", row.get("Close", 0))), 4),
812
+ "high": round(float(row.get("High", row.get("Close", 0))), 4),
813
+ "low": round(float(row.get("Low", row.get("Close", 0))), 4),
814
+ "close": round(float(row.get("Close", 0)), 4),
815
+ "volume": int(float(row.get("Volume", 0) or 0)),
816
+ })
817
+ if not records:
818
+ raise ValueError("empty Stooq records")
819
+ return {
820
+ "success": True,
821
+ "symbol": symbol.upper(),
822
+ "data": records,
823
+ "provider": "stooq",
824
+ "provider_chain": ["yfinance", "finnhub", "stooq"],
825
+ "count": len(records),
826
+ }
827
+ except Exception as exc:
828
+ return {"success": False, "error": str(exc), "symbol": symbol}
829
+
830
+ def _history_yahoo_chart(self, symbol: str, days: int, interval: str = "1d") -> Dict[str, Any]:
831
+ """Direct Yahoo chart endpoint fallback independent of yfinance objects."""
832
+ iv_map = {"1d": "1d", "1h": "1h", "15m": "15m", "5m": "5m"}
833
+ iv = iv_map.get(interval, "1d")
834
+ p2 = int(time.time())
835
+ p1 = p2 - max(days + 5, 30) * 86400
836
+ try:
837
+ r = self._sess.get(
838
+ f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}",
839
+ params={
840
+ "period1": p1,
841
+ "period2": p2,
842
+ "interval": iv,
843
+ "events": "history",
844
+ "includeAdjustedClose": "true",
845
+ },
846
+ headers={"User-Agent": "Mozilla/5.0", "Referer": "https://finance.yahoo.com/"},
847
+ timeout=12,
848
+ )
849
+ data = r.json()
850
+ result = (data.get("chart", {}).get("result") or [None])[0]
851
+ if not result:
852
+ raise ValueError("empty Yahoo chart result")
853
+ quote = ((result.get("indicators") or {}).get("quote") or [{}])[0]
854
+ timestamps = result.get("timestamp") or []
855
+ closes = quote.get("close") or []
856
+ def _q_at(name: str, idx: int, fallback=0):
857
+ values = quote.get(name) or []
858
+ try:
859
+ value = values[idx]
860
+ return fallback if value is None else value
861
+ except Exception:
862
+ return fallback
863
+ records = []
864
+ for idx, ts in enumerate(timestamps):
865
+ try:
866
+ close = closes[idx]
867
+ if close is None:
868
+ continue
869
+ records.append({
870
+ "date": datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d"),
871
+ "open": round(float(_q_at("open", idx, close) or close), 4),
872
+ "high": round(float(_q_at("high", idx, close) or close), 4),
873
+ "low": round(float(_q_at("low", idx, close) or close), 4),
874
+ "close": round(float(close), 4),
875
+ "volume": int(float(_q_at("volume", idx, 0) or 0)),
876
+ })
877
+ except Exception:
878
+ continue
879
+ if not records:
880
+ raise ValueError("empty Yahoo chart records")
881
+ return {
882
+ "success": True,
883
+ "symbol": symbol.upper(),
884
+ "data": records,
885
+ "provider": "yahoo_chart",
886
+ "provider_chain": ["yfinance", "yahoo_chart"],
887
+ "count": len(records),
888
+ }
889
+ except Exception as exc:
890
+ return {"success": False, "error": str(exc), "symbol": symbol}
891
+
892
+ def _quote_stooq(self, symbol: str) -> Dict[str, Any]:
893
+ hist = self._history_stooq(symbol, days=7, interval="1d")
894
+ if not hist.get("success"):
895
+ return hist
896
+ records = hist.get("data") or []
897
+ if not records:
898
+ return {"success": False, "error": "empty Stooq quote records", "symbol": symbol}
899
+ last = records[-1]
900
+ prev = records[-2] if len(records) >= 2 else last
901
+ price = float(last.get("close") or 0)
902
+ prev_close = float(prev.get("close") or price)
903
+ change = price - prev_close
904
+ change_pct = change / prev_close * 100 if prev_close else 0
905
+ return {
906
+ "success": True,
907
+ "symbol": symbol.upper(),
908
+ "name": symbol.upper(),
909
+ "price": round(price, 4),
910
+ "change": round(change, 4),
911
+ "change_pct": round(change_pct, 2),
912
+ "volume": int(last.get("volume") or 0),
913
+ "market_cap": None,
914
+ "high": round(float(last.get("high") or price), 2),
915
+ "low": round(float(last.get("low") or price), 2),
916
+ "open": round(float(last.get("open") or price), 4),
917
+ "prev_close": round(prev_close, 4),
918
+ "currency": "USD",
919
+ "market": "GLOBAL",
920
+ "provider": "stooq",
921
+ "provider_chain": ["yfinance", "finnhub", "stooq"],
922
+ "timestamp": datetime.now().isoformat(),
923
+ }
924
+
925
+ def _quote_yahoo_chart(self, symbol: str) -> Dict[str, Any]:
926
+ hist = self._history_yahoo_chart(symbol, days=7, interval="1d")
927
+ if not hist.get("success"):
928
+ return hist
929
+ records = hist.get("data") or []
930
+ if not records:
931
+ return {"success": False, "error": "empty Yahoo chart quote records", "symbol": symbol}
932
+ last = records[-1]
933
+ prev = records[-2] if len(records) >= 2 else last
934
+ price = float(last.get("close") or 0)
935
+ prev_close = float(prev.get("close") or price)
936
+ if price <= 0:
937
+ return {"success": False, "error": "price=0 from Yahoo chart", "symbol": symbol}
938
+ change = price - prev_close
939
+ change_pct = change / prev_close * 100 if prev_close else 0
940
+ meta_currency = ""
941
+ return {
942
+ "success": True,
943
+ "symbol": symbol.upper(),
944
+ "name": symbol.upper(),
945
+ "price": round(price, 4),
946
+ "change": round(change, 4),
947
+ "change_pct": round(change_pct, 2),
948
+ "volume": int(last.get("volume") or 0),
949
+ "market_cap": None,
950
+ "high": round(float(last.get("high") or price), 2),
951
+ "low": round(float(last.get("low") or price), 2),
952
+ "open": round(float(last.get("open") or price), 4),
953
+ "prev_close": round(prev_close, 4),
954
+ "currency": meta_currency or "USD",
955
+ "market": "GLOBAL",
956
+ "provider": "yahoo_chart",
957
+ "provider_chain": ["yfinance", "yahoo_chart"],
958
+ "timestamp": datetime.now().isoformat(),
959
+ }
960
+
961
+ def _history_yfinance(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
962
+ try:
963
+ import yfinance as yf
964
+ except Exception:
965
+ yc = self._history_yahoo_chart(symbol, days, interval)
966
+ if yc.get("success"):
967
+ return yc
968
+ stooq = self._history_stooq(symbol, days, interval)
969
+ if stooq.get("success"):
970
+ return stooq
971
+ return {"success": False, "error": "yfinance unavailable", "symbol": symbol}
972
+ period_map = {1: "5d", 5: "5d", 30: "1mo", 60: "3mo",
973
+ 90: "3mo", 120: "6mo", 180: "6mo",
974
+ 252: "1y", 365: "1y", 730: "2y", 1260: "5y"}
975
+ period = period_map.get(days) or f"{days}d"
976
+ iv_map = {"1d": "1d", "1h": "1h", "15m": "15m", "5m": "5m"}
977
+ iv = iv_map.get(interval, "1d")
978
+
979
+ def _df_to_records(df) -> list:
980
+ records = []
981
+ for ts, row in df.iterrows():
982
+ records.append({
983
+ "date": str(ts.date()) if hasattr(ts, "date") else str(ts)[:10],
984
+ "open": round(float(row.get("Open", row.get("open", 0))), 4),
985
+ "high": round(float(row.get("High", row.get("high", 0))), 4),
986
+ "low": round(float(row.get("Low", row.get("low", 0))), 4),
987
+ "close": round(float(row.get("Close", row.get("close", 0))), 4),
988
+ "volume": int(row.get("Volume", row.get("volume", 0))),
989
+ })
990
+ return records
991
+
992
+ # Primary: Ticker.history() with bounded exponential backoff on rate
993
+ # limits (1s, 2s) — gives the provider time to recover before falling
994
+ # through to the download/finnhub fallbacks. Total ≤3s so it stays
995
+ # well within per-agent timeouts.
996
+ for _attempt in range(3):
997
+ try:
998
+ df = yf.Ticker(symbol).history(period=period, interval=iv, auto_adjust=True)
999
+ if not df.empty:
1000
+ records = _df_to_records(df)
1001
+ return {"success": True, "symbol": symbol, "data": records,
1002
+ "provider": "yfinance", "count": len(records)}
1003
+ break # empty (not a rate limit) → go straight to fallback
1004
+ except Exception as _e:
1005
+ _err = str(_e).lower()
1006
+ _is_rl = any(t in _err for t in ("too many", "rate", "429"))
1007
+ if _is_rl and _attempt < 2:
1008
+ logger.debug("yfinance history rate-limited for %s, backoff %ss",
1009
+ symbol, 2 ** _attempt)
1010
+ time.sleep(1.0 * (2 ** _attempt)) # 1s, then 2s
1011
+ continue
1012
+ logger.debug("yfinance history primary failed for %s: %s — trying download fallback", symbol, _e)
1013
+ break
1014
+
1015
+ # Fallback: yf.download() uses a different API endpoint, more resilient to rate limits
1016
+ try:
1017
+ from datetime import date, timedelta as _td
1018
+ end_dt = date.today()
1019
+ start_dt = end_dt - _td(days=days + 5)
1020
+ df2 = yf.download(symbol, start=start_dt.isoformat(), end=end_dt.isoformat(),
1021
+ interval=iv, auto_adjust=True, progress=False, timeout=15)
1022
+ if not df2.empty:
1023
+ # yf.download may return MultiIndex columns when single ticker
1024
+ if hasattr(df2.columns, "levels"):
1025
+ df2.columns = df2.columns.droplevel(1) if len(df2.columns.levels) > 1 else df2.columns
1026
+ records = _df_to_records(df2)
1027
+ return {"success": True, "symbol": symbol, "data": records,
1028
+ "provider": "yfinance_download", "count": len(records)}
1029
+ except Exception as _e:
1030
+ logger.debug("yfinance download fallback also failed for %s: %s", symbol, _e)
1031
+
1032
+ # Finnhub candle fallback
1033
+ yc = self._history_yahoo_chart(symbol, days, interval)
1034
+ if yc.get("success"):
1035
+ return yc
1036
+
1037
+ if self._fh_key:
1038
+ fh = self._history_finnhub(symbol, days, interval)
1039
+ if fh.get("success"):
1040
+ return fh
1041
+
1042
+ stooq = self._history_stooq(symbol, days, interval)
1043
+ if stooq.get("success"):
1044
+ return stooq
1045
+
1046
+ return {
1047
+ "success": False,
1048
+ "error": (
1049
+ "global history unavailable: "
1050
+ f"yfinance/yahoo_chart/finnhub/stooq failed; "
1051
+ f"yahoo_chart={yc.get('error')}; stooq={stooq.get('error')}"
1052
+ ),
1053
+ "symbol": symbol,
1054
+ "provider_chain": ["yfinance", "yahoo_chart", "finnhub", "stooq"],
1055
+ }
1056
+
1057
+ # ── A-share (Eastmoney push2 API) ────────────────────────────────────────
1058
+
1059
+ def _quote_ashare(self, symbol: str) -> Dict[str, Any]:
1060
+ """A股报价: 用户配置的 Tushare 优先(若有),再东方财富,yfinance 末级 fallback."""
1061
+ code = _normalise_ashare(symbol)
1062
+ errors: List[str] = []
1063
+
1064
+ # ── 优先路径: 用户配置的 Tushare(仅当 TUSHARE_TOKEN 已设置)──────────
1065
+ _ts = self._tushare()
1066
+ if _ts is not None:
1067
+ try:
1068
+ q = _ts.quote(code)
1069
+ if q is not None and _is_valid_price(float(q.price or 0)):
1070
+ return {
1071
+ "success": True,
1072
+ "symbol": code,
1073
+ "name": q.name or code,
1074
+ "price": round(float(q.price), 4),
1075
+ "change": round(float(q.change or 0), 4),
1076
+ "change_pct": round(float(q.change_pct or 0), 2),
1077
+ "volume": int(float(q.volume or 0)),
1078
+ "currency": "CNY",
1079
+ "market": "CN",
1080
+ "provider": "tushare",
1081
+ "provider_chain": ["tushare"],
1082
+ "timestamp": q.timestamp or datetime.now().isoformat(),
1083
+ }
1084
+ except Exception as ts_err:
1085
+ errors.append(f"tushare: {ts_err}")
1086
+ logger.debug("Tushare A-share quote failed %s: %s", code, ts_err)
1087
+
1088
+ # ── 主路径: 东方财富 push2 API ─────────────────────────────────
1089
+ secid = _ashare_secid(code)
1090
+ try:
1091
+ _resp = self._em_get_json(self.EM_QUOTE_URL, {
1092
+ "secid": secid,
1093
+ "fields": self._EM_FIELDS,
1094
+ "ut": _EM_UT,
1095
+ "fltt": 2, "invt": 2,
1096
+ }, timeout=6)
1097
+ d = (_resp or {}).get("data", {}) or {}
1098
+ price = float(d.get("f43", 0))
1099
+ if not _is_valid_price(price):
1100
+ raise ValueError("empty Eastmoney quote")
1101
+ chg = float(d.get("f169", 0))
1102
+ chg_pct = float(d.get("f170", 0))
1103
+ prev = round(price - chg, 4) # f46=今开(open), not 昨收; derive from change
1104
+ return {
1105
+ "success": True,
1106
+ "symbol": code,
1107
+ "name": d.get("f58", code),
1108
+ "price": price,
1109
+ "change": chg,
1110
+ "change_pct": chg_pct,
1111
+ "volume": int(d.get("f47", 0)),
1112
+ "turnover": float(d.get("f48", 0)),
1113
+ "market_cap": float(d.get("f116", 0)) * 1e4,
1114
+ "high": float(d.get("f44", 0)),
1115
+ "low": float(d.get("f45", 0)),
1116
+ "open": float(d.get("f46", 0)),
1117
+ "prev_close": prev,
1118
+ "currency": "CNY",
1119
+ "market": "CN",
1120
+ "provider": "eastmoney",
1121
+ "provider_chain": ["eastmoney"],
1122
+ "timestamp": datetime.now().isoformat(),
1123
+ }
1124
+ except Exception as em_err:
1125
+ errors.append(f"eastmoney: {em_err}")
1126
+ logger.debug("Eastmoney A-share failed %s: %s", code, em_err)
1127
+
1128
+ # ── 备用路径 1: 腾讯行情 qt.gtimg.cn ─────────────────────────────────
1129
+ try:
1130
+ prefix = "sz" if code.startswith(("0", "3")) else "sh"
1131
+ r = self._sess.get(f"https://qt.gtimg.cn/q={prefix}{code}", timeout=6)
1132
+ raw = r.text.strip()
1133
+ if raw and "=" in raw:
1134
+ val = raw.split("=", 1)[1].strip().strip('"').strip("'")
1135
+ flds = val.split("~")
1136
+ if len(flds) > 10 and flds[3]:
1137
+ price = float(flds[3])
1138
+ prev = float(flds[4] or price)
1139
+ chg = price - prev
1140
+ chg_p = chg / prev * 100 if prev else 0
1141
+ if _is_valid_price(price):
1142
+ return {
1143
+ "success": True,
1144
+ "symbol": code,
1145
+ "name": flds[1] or code,
1146
+ "price": round(price, 4),
1147
+ "change": round(chg, 4),
1148
+ "change_pct": round(chg_p, 2),
1149
+ "volume": int(float(flds[6] or 0)) * 100,
1150
+ "high": round(float(flds[33] if len(flds) > 33 and flds[33] else price), 2),
1151
+ "low": round(float(flds[34] if len(flds) > 34 and flds[34] else price), 2),
1152
+ "open": round(float(flds[5] or price), 4),
1153
+ "prev_close": round(prev, 4),
1154
+ "currency": "CNY",
1155
+ "market": "CN",
1156
+ "provider": "tencent",
1157
+ "provider_chain": ["eastmoney", "tencent"],
1158
+ "timestamp": datetime.now().isoformat(),
1159
+ }
1160
+ raise ValueError(f"invalid tencent price: {flds[3] if len(flds) > 3 else 'N/A'}")
1161
+ except Exception as tx_err:
1162
+ errors.append(f"tencent: {tx_err}")
1163
+ logger.debug("Tencent A-share failed %s: %s", code, tx_err)
1164
+
1165
+ # ── 备用路径 2: 新浪行情 hq.sinajs.cn ────────────────────────────────
1166
+ try:
1167
+ prefix = "sz" if code.startswith(("0", "3")) else "sh"
1168
+ r = self._sess.get(f"https://hq.sinajs.cn/list={prefix}{code}",
1169
+ headers={"Referer": "https://finance.sina.com.cn/"},
1170
+ timeout=6)
1171
+ raw = r.text.strip()
1172
+ if raw and "=" in raw:
1173
+ val = raw.split("=", 1)[1].strip().strip('"').strip("'")
1174
+ flds = val.split(",")
1175
+ if len(flds) > 9 and flds[3]:
1176
+ price = float(flds[3])
1177
+ prev = float(flds[2] or price)
1178
+ chg = price - prev
1179
+ chg_p = chg / prev * 100 if prev else 0
1180
+ if _is_valid_price(price):
1181
+ return {
1182
+ "success": True,
1183
+ "symbol": code,
1184
+ "name": flds[0] or code,
1185
+ "price": round(price, 4),
1186
+ "change": round(chg, 4),
1187
+ "change_pct": round(chg_p, 2),
1188
+ "volume": int(float(flds[8] or 0)),
1189
+ "turnover": float(flds[9] or 0),
1190
+ "high": round(float(flds[4] or price), 2),
1191
+ "low": round(float(flds[5] or price), 2),
1192
+ "open": round(float(flds[1] or price), 4),
1193
+ "prev_close": round(prev, 4),
1194
+ "currency": "CNY",
1195
+ "market": "CN",
1196
+ "provider": "sina",
1197
+ "provider_chain": ["eastmoney", "tencent", "sina"],
1198
+ "timestamp": datetime.now().isoformat(),
1199
+ }
1200
+ raise ValueError(f"invalid sina price: {flds[3] if len(flds) > 3 else 'N/A'}")
1201
+ except Exception as sina_err:
1202
+ errors.append(f"sina: {sina_err}")
1203
+ logger.debug("Sina A-share failed %s: %s", code, sina_err)
1204
+
1205
+ # ── 备用路径 3: AKShare snapshot(如果本地安装)──────────────────────
1206
+ # AKShare uses its own requests sessions; clear proxy env vars so it
1207
+ # connects directly instead of routing through the China VPN (which
1208
+ # rejects AKShare's Eastmoney endpoints with ProxyError).
1209
+ try:
1210
+ import akshare as ak
1211
+ import os as _ak_os
1212
+ _ak_proxy_bk = {k: _ak_os.environ.pop(k, None)
1213
+ for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
1214
+ try:
1215
+ df = ak.stock_zh_a_spot_em()
1216
+ finally:
1217
+ for _k, _v in _ak_proxy_bk.items():
1218
+ if _v is not None: _ak_os.environ[_k] = _v
1219
+ row = df[df["代码"].astype(str) == code]
1220
+ if row.empty:
1221
+ raise ValueError("empty AKShare quote")
1222
+ item = row.iloc[0]
1223
+ price = float(item.get("最新价", 0))
1224
+ if not _is_valid_price(price):
1225
+ raise ValueError("empty AKShare price")
1226
+ return {
1227
+ "success": True,
1228
+ "symbol": code,
1229
+ "name": str(item.get("名称", code)),
1230
+ "price": price,
1231
+ "change": float(item.get("涨跌额", 0) or 0),
1232
+ "change_pct": float(item.get("涨跌幅", 0) or 0),
1233
+ "volume": int(float(item.get("成交量", 0) or 0)),
1234
+ "turnover": float(item.get("成交额", 0) or 0),
1235
+ "market_cap": float(item.get("总市值", 0) or 0),
1236
+ "high": float(item.get("最高", 0) or 0),
1237
+ "low": float(item.get("最低", 0) or 0),
1238
+ "open": float(item.get("今开", 0) or 0),
1239
+ "prev_close": float(item.get("昨收", 0) or 0),
1240
+ "currency": "CNY",
1241
+ "market": "CN",
1242
+ "provider": "akshare",
1243
+ "provider_chain": ["eastmoney", "tencent", "sina", "akshare"],
1244
+ "timestamp": datetime.now().isoformat(),
1245
+ }
1246
+ except Exception as ak_err:
1247
+ errors.append(f"akshare: {ak_err}")
1248
+ logger.debug("AKShare A-share failed %s: %s", code, ak_err)
1249
+
1250
+ # ── 末级 fallback: yfinance via Yahoo Finance(全球可访问,明确绕过代理)──
1251
+ # Yahoo Finance is accessible globally; bypass any China-routing proxy so
1252
+ # this fallback works even when the VPN/proxy is down.
1253
+ try:
1254
+ import yfinance as yf
1255
+ import os as _os
1256
+ suffix = ".SS" if code.startswith(("6", "688", "83", "87")) else ".SZ"
1257
+ yf_sym = code + suffix
1258
+ # Temporarily clear proxy env vars so yfinance connects directly to Yahoo
1259
+ _proxy_backup = {k: _os.environ.pop(k, None)
1260
+ for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
1261
+ try:
1262
+ t = yf.Ticker(yf_sym)
1263
+ fi = t.fast_info
1264
+ price = float(fi.last_price or 0)
1265
+ if not _is_valid_price(price):
1266
+ # fast_info may return None outside trading hours; use history
1267
+ h = t.history(period="2d", auto_adjust=True)
1268
+ if h.empty:
1269
+ raise ValueError("empty yfinance history")
1270
+ price = float(h["Close"].iloc[-1])
1271
+ prev = float(h["Close"].iloc[-2]) if len(h) >= 2 else price
1272
+ else:
1273
+ prev = float(fi.previous_close or price)
1274
+ finally:
1275
+ for k, v in _proxy_backup.items():
1276
+ if v is not None:
1277
+ _os.environ[k] = v
1278
+ if not _is_valid_price(price):
1279
+ raise ValueError("empty yfinance quote")
1280
+ chg = price - prev
1281
+ chg_p = chg / prev * 100 if prev else 0
1282
+ return {
1283
+ "success": True,
1284
+ "symbol": code,
1285
+ "name": code,
1286
+ "price": round(price, 4),
1287
+ "change": round(chg, 4),
1288
+ "change_pct": round(chg_p, 2),
1289
+ "volume": int(getattr(fi, "three_month_average_volume", None) or 0),
1290
+ "market_cap": getattr(fi, "market_cap", None),
1291
+ "high": round(float(getattr(fi, "day_high", None) or 0), 2),
1292
+ "low": round(float(getattr(fi, "day_low", None) or 0), 2),
1293
+ "open": round(float(getattr(fi, "open", None) or 0), 4),
1294
+ "prev_close": round(prev, 4),
1295
+ "currency": "CNY",
1296
+ "market": "CN",
1297
+ "provider": "yfinance",
1298
+ "provider_chain": ["eastmoney", "tencent", "sina", "akshare", "yfinance"],
1299
+ "timestamp": datetime.now().isoformat(),
1300
+ }
1301
+ except Exception as yf_err:
1302
+ errors.append(f"yfinance: {yf_err}")
1303
+ logger.debug("yfinance A-share failed %s: %s", code, yf_err)
1304
+
1305
+ return {
1306
+ "success": False,
1307
+ "symbol": code,
1308
+ "market": "CN",
1309
+ "provider_chain": ["eastmoney", "tencent", "sina", "akshare", "yfinance"],
1310
+ "error": _friendly_market_error(
1311
+ code, ["Eastmoney", "腾讯", "新浪", "AKShare", "Yahoo Finance"],
1312
+ "; ".join(errors)
1313
+ ),
1314
+ "debug_error": "; ".join(errors),
1315
+ }
1316
+
1317
+ def _history_ashare(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
1318
+ code = _normalise_ashare(symbol)
1319
+ secid = _ashare_secid(code)
1320
+ errors: List[str] = []
1321
+ klt_map = {"1d": 101, "1w": 102, "1mo": 103, "1h": 60, "30m": 30}
1322
+ klt = klt_map.get(interval, 101)
1323
+ end_date = datetime.now().strftime("%Y%m%d%H%M%S")
1324
+
1325
+ # ── 优先路径: 用户配置的 Tushare(仅日线,仅当 TUSHARE_TOKEN 已设置)──
1326
+ _ts = self._tushare()
1327
+ if _ts is not None and interval == "1d":
1328
+ try:
1329
+ h = _ts.history(code, days=days)
1330
+ if h is not None and h.data is not None and not h.data.empty:
1331
+ records = []
1332
+ for idx, row in h.data.iterrows():
1333
+ records.append({
1334
+ "date": idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx)[:10],
1335
+ "open": float(row.get("open", 0) or 0),
1336
+ "high": float(row.get("high", 0) or 0),
1337
+ "low": float(row.get("low", 0) or 0),
1338
+ "close": float(row.get("close", 0) or 0),
1339
+ "volume": int(float(row.get("volume", 0) or 0)),
1340
+ })
1341
+ if records:
1342
+ return {"success": True, "symbol": code, "name": code,
1343
+ "data": records, "provider": "tushare",
1344
+ "provider_chain": ["tushare"], "count": len(records)}
1345
+ except Exception as ts_err:
1346
+ errors.append(f"tushare: {ts_err}")
1347
+ logger.debug("Tushare history failed %s: %s", code, ts_err)
1348
+
1349
+ # ── 主路径: 东方财富历史 K线(通过系统代理)──────────────────────────
1350
+ try:
1351
+ _resp = self._em_get_json(self.EM_HIST_URL, {
1352
+ "secid": secid,
1353
+ "klt": klt,
1354
+ "fqt": 1, # 前复权
1355
+ "lmt": days + 50,
1356
+ "end": end_date,
1357
+ "fields1": "f1,f2,f3,f4,f5,f6",
1358
+ "fields2": "f51,f52,f53,f54,f55,f56",
1359
+ "ut": _EM_UT,
1360
+ }, timeout=10)
1361
+ raw = (_resp or {}).get("data", {}) or {}
1362
+ name = raw.get("name", code)
1363
+ klines = raw.get("klines", [])
1364
+ records = []
1365
+ for k in klines:
1366
+ parts = k.split(",")
1367
+ if len(parts) >= 6:
1368
+ records.append({
1369
+ "date": parts[0],
1370
+ "open": float(parts[1]),
1371
+ "close": float(parts[2]),
1372
+ "high": float(parts[3]),
1373
+ "low": float(parts[4]),
1374
+ "volume": int(float(parts[5])),
1375
+ })
1376
+ if records:
1377
+ return {"success": True, "symbol": code, "name": name,
1378
+ "data": records, "provider": "eastmoney",
1379
+ "provider_chain": ["eastmoney"], "count": len(records)}
1380
+ raise ValueError("empty Eastmoney kline response")
1381
+ except Exception as em_err:
1382
+ errors.append(f"eastmoney: {em_err}")
1383
+ logger.debug("Eastmoney history failed %s: %s", code, em_err)
1384
+
1385
+ # ── 备用 1: 新浪 K线(scale=240 = 日线,datalen ≈ days)────────────────
1386
+ try:
1387
+ import json as _json
1388
+ prefix = "sz" if code.startswith(("0", "3")) else "sh"
1389
+ datalen = min(max(days, 60), 1023)
1390
+ r = self._sess.get(
1391
+ "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData",
1392
+ params={"symbol": f"{prefix}{code}", "scale": 240, "ma": "no", "datalen": datalen},
1393
+ headers={"Referer": "https://finance.sina.com.cn/"},
1394
+ timeout=10,
1395
+ )
1396
+ raw_list = _json.loads(r.text) if r.status_code == 200 else []
1397
+ records = []
1398
+ for item in raw_list:
1399
+ records.append({
1400
+ "date": item["day"],
1401
+ "open": float(item.get("open", 0)),
1402
+ "high": float(item.get("high", 0)),
1403
+ "low": float(item.get("low", 0)),
1404
+ "close": float(item.get("close", 0)),
1405
+ "volume": int(float(item.get("volume", 0))),
1406
+ })
1407
+ if records:
1408
+ return {"success": True, "symbol": code, "name": code,
1409
+ "data": records, "provider": "sina",
1410
+ "provider_chain": ["eastmoney", "sina"], "count": len(records)}
1411
+ raise ValueError("empty Sina kline response")
1412
+ except Exception as sina_err:
1413
+ errors.append(f"sina: {sina_err}")
1414
+ logger.debug("Sina history failed %s: %s", code, sina_err)
1415
+
1416
+ # ── 备用 2: AKShare 历史数据(如果本地安装)──────────────────────────
1417
+ if interval == "1d":
1418
+ try:
1419
+ import akshare as ak
1420
+ import os as _ak_os
1421
+ start_date = (datetime.now() - timedelta(days=days + 30)).strftime("%Y%m%d")
1422
+ end_day = datetime.now().strftime("%Y%m%d")
1423
+ _ak_proxy_bk = {k: _ak_os.environ.pop(k, None)
1424
+ for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
1425
+ try:
1426
+ df = ak.stock_zh_a_hist(
1427
+ symbol=code,
1428
+ period="daily",
1429
+ start_date=start_date,
1430
+ end_date=end_day,
1431
+ adjust="qfq",
1432
+ )
1433
+ finally:
1434
+ for _k, _v in _ak_proxy_bk.items():
1435
+ if _v is not None:
1436
+ _ak_os.environ[_k] = _v
1437
+ if df.empty:
1438
+ raise ValueError("empty AKShare history")
1439
+ records = []
1440
+ for _, row in df.tail(days + 5).iterrows():
1441
+ records.append({
1442
+ "date": str(row.get("日期", ""))[:10],
1443
+ "open": float(row.get("开盘", 0) or 0),
1444
+ "high": float(row.get("最高", 0) or 0),
1445
+ "low": float(row.get("最低", 0) or 0),
1446
+ "close": float(row.get("收盘", 0) or 0),
1447
+ "volume": int(float(row.get("成交量", 0) or 0)),
1448
+ })
1449
+ if records:
1450
+ return {"success": True, "symbol": code, "name": code,
1451
+ "data": records, "provider": "akshare",
1452
+ "provider_chain": ["eastmoney", "sina", "akshare"],
1453
+ "count": len(records)}
1454
+ raise ValueError("empty AKShare records")
1455
+ except Exception as ak_err:
1456
+ errors.append(f"akshare: {ak_err}")
1457
+ logger.debug("AKShare history failed %s: %s", code, ak_err)
1458
+ else:
1459
+ errors.append(f"akshare: unsupported interval {interval}")
1460
+
1461
+ # ── 备用 3: yfinance Yahoo Finance(绕过代理,全球可访问)────────────────
1462
+ try:
1463
+ import yfinance as yf
1464
+ import os as _os
1465
+ suffix = ".SS" if code.startswith(("6", "688", "83", "87")) else ".SZ"
1466
+ yf_sym = code + suffix
1467
+ period_map = {30: "1mo", 60: "3mo", 90: "3mo", 120: "6mo",
1468
+ 180: "6mo", 252: "1y", 365: "1y", 730: "2y"}
1469
+ period = period_map.get(days) or f"{days}d"
1470
+ iv_map = {"1d": "1d", "1h": "1h", "15m": "15m"}
1471
+ iv = iv_map.get(interval, "1d")
1472
+
1473
+ _proxy_backup = {k: _os.environ.pop(k, None)
1474
+ for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")}
1475
+ try:
1476
+ df = yf.Ticker(yf_sym).history(period=period, interval=iv, auto_adjust=True)
1477
+ if df.empty:
1478
+ df = yf.download(yf_sym, period=period, interval=iv,
1479
+ auto_adjust=True, progress=False, timeout=15)
1480
+ if hasattr(df.columns, "levels") and len(df.columns.levels) > 1:
1481
+ df.columns = df.columns.droplevel(1)
1482
+ finally:
1483
+ for k, v in _proxy_backup.items():
1484
+ if v is not None:
1485
+ _os.environ[k] = v
1486
+
1487
+ if df.empty:
1488
+ raise ValueError("empty yfinance dataframe")
1489
+ records = []
1490
+ for ts, row in df.iterrows():
1491
+ records.append({
1492
+ "date": str(ts.date()) if hasattr(ts, "date") else str(ts)[:10],
1493
+ "open": round(float(row.get("Open", row.get("open", 0))), 4),
1494
+ "high": round(float(row.get("High", row.get("high", 0))), 4),
1495
+ "low": round(float(row.get("Low", row.get("low", 0))), 4),
1496
+ "close": round(float(row.get("Close", row.get("close", 0))), 4),
1497
+ "volume": int(row.get("Volume", row.get("volume", 0))),
1498
+ })
1499
+ return {"success": True, "symbol": code, "name": code,
1500
+ "data": records, "provider": "yfinance",
1501
+ "provider_chain": ["eastmoney", "sina", "akshare", "yfinance"],
1502
+ "count": len(records)}
1503
+ except Exception as yf_err:
1504
+ errors.append(f"yfinance: {yf_err}")
1505
+ logger.debug("yfinance history fallback failed %s: %s", code, yf_err)
1506
+ return {
1507
+ "success": False,
1508
+ "symbol": code,
1509
+ "market": "CN",
1510
+ "provider_chain": ["eastmoney", "sina", "akshare", "yfinance"],
1511
+ "error": _friendly_market_error(
1512
+ code, ["Eastmoney", "新浪", "AKShare", "Yahoo Finance"],
1513
+ "; ".join(errors),
1514
+ ),
1515
+ "debug_error": "; ".join(errors),
1516
+ }
1517
+
1518
+ def _fundamentals_ashare(self, symbol: str) -> Dict[str, Any]:
1519
+ """A股基本面:东方财富个股资金流."""
1520
+ code = _normalise_ashare(symbol)
1521
+ # 通过 yfinance 尝试 (港股 / ADR)
1522
+ try:
1523
+ import yfinance as yf
1524
+ yf_sym = code + ".SS" if code.startswith("6") else code + ".SZ"
1525
+ return self._quote_yfinance(yf_sym)
1526
+ except Exception as e:
1527
+ return {"success": False, "error": str(e), "symbol": symbol}
1528
+
1529
+ # ── Crypto (ccxt) ────────────────────────────────────────────────────────
1530
+
1531
+ def _quote_crypto(self, symbol: str) -> Dict[str, Any]:
1532
+ try:
1533
+ import ccxt
1534
+ sym = _norm_crypto(symbol)
1535
+ ex = ccxt.binance({"enableRateLimit": True,
1536
+ "proxies": {"http": "", "https": ""}})
1537
+ ticker = ex.fetch_ticker(sym)
1538
+ price = float(ticker["last"] or 0)
1539
+ prev = float(ticker.get("previousClose") or ticker.get("open") or price)
1540
+ chg = price - prev
1541
+ chg_p = chg / prev * 100 if prev else 0
1542
+ return {
1543
+ "success": True,
1544
+ "symbol": sym,
1545
+ "price": price,
1546
+ "change": round(chg, 6),
1547
+ "change_pct": round(chg_p, 2),
1548
+ "volume": float(ticker.get("baseVolume", 0)),
1549
+ "high": float(ticker.get("high", 0) or 0),
1550
+ "low": float(ticker.get("low", 0) or 0),
1551
+ "market_cap": None,
1552
+ "currency": "USDT",
1553
+ "market": "CRYPTO",
1554
+ "provider": "ccxt/binance",
1555
+ "timestamp": datetime.now().isoformat(),
1556
+ }
1557
+ except Exception as e:
1558
+ # fallback to yfinance
1559
+ yf_sym = symbol.replace("/","").replace("USDT","-USD")
1560
+ return self._quote_yfinance(yf_sym)
1561
+
1562
+ def _history_crypto(self, symbol: str, days: int, interval: str) -> Dict[str, Any]:
1563
+ try:
1564
+ import ccxt
1565
+ sym = _norm_crypto(symbol)
1566
+ iv_map = {"1d":"1d","1h":"1h","15m":"15m","4h":"4h"}
1567
+ tf = iv_map.get(interval, "1d")
1568
+ limit = min(days, 1000)
1569
+ ex = ccxt.binance({"enableRateLimit": True,
1570
+ "proxies": {"http": "", "https": ""}})
1571
+ ohlcv = ex.fetch_ohlcv(sym, timeframe=tf, limit=limit)
1572
+ records = [{"date": datetime.fromtimestamp(c[0] / 1000, tz=timezone.utc).strftime("%Y-%m-%d"),
1573
+ "open": c[1], "high": c[2], "low": c[3],
1574
+ "close": c[4], "volume": c[5]}
1575
+ for c in ohlcv]
1576
+ return {"success": True, "symbol": sym, "data": records,
1577
+ "provider": "ccxt/binance", "count": len(records)}
1578
+ except Exception as e:
1579
+ yf_sym = symbol.replace("/","").replace("USDT","-USD")
1580
+ return self._history_yfinance(yf_sym, days, interval)
1581
+
1582
+ # ── Binance: funding rate + read-only account ──────────────────────────────
1583
+
1584
+ def crypto_funding_rate(self, symbol: str, exchange: str = "binance") -> Dict[str, Any]:
1585
+ """Perpetual funding rate (read-only, no key). Falls back across
1586
+ exchanges so a geo-blocked Binance doesn't kill the lookup."""
1587
+ import ccxt
1588
+ sym = _norm_crypto(symbol) + ":USDT" # ccxt linear-perp notation
1589
+ _last_err = ""
1590
+ for exch in [exchange] + [e for e in ("okx", "bybit", "gate") if e != exchange]:
1591
+ try:
1592
+ ex = getattr(ccxt, exch)({"enableRateLimit": True,
1593
+ "options": {"defaultType": "swap"},
1594
+ "proxies": {"http": "", "https": ""}})
1595
+ fr = ex.fetch_funding_rate(sym)
1596
+ return {
1597
+ "success": True, "symbol": sym, "exchange": exch,
1598
+ "funding_rate": fr.get("fundingRate"),
1599
+ "funding_rate_pct": round((fr.get("fundingRate") or 0) * 100, 4),
1600
+ "next_funding": fr.get("fundingDatetime"),
1601
+ "mark_price": fr.get("markPrice"),
1602
+ "provider": f"ccxt/{exch}",
1603
+ }
1604
+ except Exception as e:
1605
+ _last_err = str(e)
1606
+ continue
1607
+ return {"success": False, "error": _last_err, "symbol": symbol}
1608
+
1609
+ def crypto_account(self, exchange: str = "binance") -> Dict[str, Any]:
1610
+ """READ-ONLY account balance via API key (no trading).
1611
+
1612
+ Keys from env: BINANCE_API_KEY / BINANCE_SECRET (or <EXCHANGE>_API_KEY).
1613
+ This only reads balances — Aria never places crypto orders.
1614
+ """
1615
+ import os as _os
1616
+ key = _os.getenv(f"{exchange.upper()}_API_KEY") or _os.getenv("BINANCE_API_KEY")
1617
+ secret = _os.getenv(f"{exchange.upper()}_SECRET") or _os.getenv("BINANCE_SECRET")
1618
+ if not key or not secret:
1619
+ return {"success": False, "error": "no_api_key",
1620
+ "hint": f"set {exchange.upper()}_API_KEY and {exchange.upper()}_SECRET (read-only key)"}
1621
+ try:
1622
+ import ccxt
1623
+ ex = getattr(ccxt, exchange)({
1624
+ "apiKey": key, "secret": secret,
1625
+ "enableRateLimit": True, "proxies": {"http": "", "https": ""},
1626
+ })
1627
+ bal = ex.fetch_balance()
1628
+ holdings = []
1629
+ for asset, amt in (bal.get("total") or {}).items():
1630
+ if amt and float(amt) > 0:
1631
+ holdings.append({"asset": asset, "amount": float(amt),
1632
+ "free": float((bal.get("free") or {}).get(asset, 0)),
1633
+ "used": float((bal.get("used") or {}).get(asset, 0))})
1634
+ holdings.sort(key=lambda h: -h["amount"])
1635
+ return {"success": True, "exchange": exchange,
1636
+ "holdings": holdings, "asset_count": len(holdings),
1637
+ "provider": f"ccxt/{exchange}", "read_only": True}
1638
+ except Exception as e:
1639
+ return {"success": False, "error": str(e), "exchange": exchange}
1640
+
1641
+ # ── Global indices ────────────────────────────────────────────────────────
1642
+
1643
+ def _fetch_indices(self) -> Dict[str, Any]:
1644
+ indices = {}
1645
+ # A股指数 (东方财富)
1646
+ cn_secids = "1.000001,0.399001,0.399006,1.000016,1.000688"
1647
+ cn_names = {"000001":"上证指数","399001":"深证成指",
1648
+ "399006":"创业板指","000016":"上证50","000688":"科创50"}
1649
+ try:
1650
+ _resp = self._em_get_json(self.EM_ULIST_URL, {
1651
+ "fltt": 2, "invt": 2,
1652
+ "fields": "f1,f2,f3,f4,f12,f14",
1653
+ "secids": cn_secids,
1654
+ "ut": _EM_UT,
1655
+ }, timeout=8)
1656
+ _diff = (_resp or {}).get("data", {}).get("diff", []) or []
1657
+ if isinstance(_diff, dict):
1658
+ _diff = list(_diff.values())
1659
+ for item in _diff:
1660
+ code = item.get("f12","")
1661
+ indices[cn_names.get(code, code)] = {
1662
+ "price": round(float(item.get("f2",0)), 2),
1663
+ "change_pct": round(float(item.get("f3",0)), 2),
1664
+ "change": round(float(item.get("f4",0)), 2),
1665
+ "market": "CN",
1666
+ }
1667
+ except Exception as e:
1668
+ logger.debug("CN indices error: %s", e)
1669
+
1670
+ # Global indices (yfinance)
1671
+ global_map = {
1672
+ "^GSPC": "S&P 500",
1673
+ "^IXIC": "纳斯达克",
1674
+ "^DJI": "道琼斯",
1675
+ "^HSI": "恒生指数",
1676
+ "^N225": "日经225",
1677
+ "^FTSE": "富时100",
1678
+ "GC=F": "黄金",
1679
+ "CL=F": "原油WTI",
1680
+ "BTC-USD":"比特币",
1681
+ }
1682
+ try:
1683
+ import yfinance as yf
1684
+ tickers = yf.Tickers(" ".join(global_map.keys()))
1685
+ for sym, name in global_map.items():
1686
+ try:
1687
+ fi = tickers.tickers[sym].fast_info
1688
+ price = float(fi.last_price or 0)
1689
+ prev = float(fi.previous_close or price)
1690
+ chg_p = (price - prev) / prev * 100 if prev else 0
1691
+ indices[name] = {
1692
+ "price": round(price, 2),
1693
+ "change_pct": round(chg_p, 2),
1694
+ "change": round(price - prev, 4),
1695
+ "market": "US" if sym.startswith("^") else "COMMOD",
1696
+ }
1697
+ except Exception as _e:
1698
+ logger.debug("Global index fetch failed for %s: %s", sym, _e)
1699
+ except Exception as e:
1700
+ logger.debug("Global indices yfinance error: %s", e)
1701
+
1702
+ return {"success": True, "indices": indices,
1703
+ "timestamp": datetime.now().isoformat()}
1704
+
1705
+ # ── 北向资金 ────────────────────────────────────────────────────────────
1706
+
1707
+ def _fetch_northbound(self) -> Dict[str, Any]:
1708
+ try:
1709
+ _resp = self._em_get_json(self.EM_NORTHBOUND, {
1710
+ "fields1": "f1,f2,f3,f4",
1711
+ "fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
1712
+ "klt": 101, "lmt": 5,
1713
+ "ut": _EM_UT,
1714
+ }, timeout=8)
1715
+ data = (_resp or {}).get("data", {}) or {}
1716
+ sh = data.get("s2n", {}) or {} # 沪股通
1717
+ sz = data.get("s3n", {}) or {} # 深股通
1718
+ def _val(obj, key):
1719
+ try: return float(obj.get(key, 0)) / 1e8 # 元 → 亿
1720
+ except (KeyError, ValueError, TypeError): return 0.0
1721
+ sh_net = _val(sh, "f2")
1722
+ sz_net = _val(sz, "f2")
1723
+ total = sh_net + sz_net
1724
+ return {
1725
+ "success": True,
1726
+ "total_net": round(total, 2),
1727
+ "sh_net": round(sh_net, 2),
1728
+ "sz_net": round(sz_net, 2),
1729
+ "unit": "亿元",
1730
+ "direction": "净流入" if total > 0 else "净流出",
1731
+ "provider": "eastmoney",
1732
+ "timestamp": datetime.now().isoformat(),
1733
+ }
1734
+ except Exception as e:
1735
+ return {"success": False, "error": str(e)}
1736
+
1737
+ # ── 热门股榜单 ────────────────────────────────────────────────────────────
1738
+
1739
+ def _fetch_hot_ashare(self, top_n: int = 20) -> Dict[str, Any]:
1740
+ try:
1741
+ data = self._em_get_json(self.EM_HOT_URL, {
1742
+ "pn": 1, "pz": top_n, "po": 1, "np": 1,
1743
+ "ut": _EM_UT,
1744
+ "fltt": 2, "invt": 2, "fid": "f6",
1745
+ "fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23",
1746
+ "fields": "f2,f3,f4,f5,f6,f7,f12,f14,f62",
1747
+ })
1748
+ if not data:
1749
+ return {"success": False, "error": "eastmoney 无响应(网络或代理)"}
1750
+ items = (data.get("data") or {}).get("diff", []) or []
1751
+ if isinstance(items, dict):
1752
+ items = list(items.values())
1753
+ stocks = []
1754
+ for d in items[:top_n]:
1755
+ stocks.append({
1756
+ "code": d.get("f12",""),
1757
+ "name": d.get("f14",""),
1758
+ "price": round(float(d.get("f2",0)), 2), # fltt=2 → already in ¥
1759
+ "change_pct": round(float(d.get("f3",0)), 2), # already in %
1760
+ "volume": int(d.get("f5",0)),
1761
+ "turnover": float(d.get("f6",0)),
1762
+ "amplitude": round(float(d.get("f7",0)), 2), # already in %
1763
+ })
1764
+ return {"success": True, "market": "CN", "stocks": stocks,
1765
+ "count": len(stocks), "provider": "eastmoney"}
1766
+ except Exception as e:
1767
+ return {"success": False, "error": str(e)}
1768
+
1769
+ def screen_ashare(self, *, max_pe: float = 50, min_market_cap_yi: float = 0,
1770
+ limit: int = 20, exclude_st: bool = True) -> Dict[str, Any]:
1771
+ """Screen A-shares via the eastmoney clist endpoint (host-rotating,
1772
+ proxy-resilient). Sorted by change% desc, then filtered by PE/market cap.
1773
+
1774
+ Fetches a few pages (not all ~5000 stocks) so the request stays small
1775
+ and reliable. Fields: f2 price, f3 chg%, f8 turnover, f9 PE(dynamic),
1776
+ f12 code, f14 name, f20 total mktcap, f23 PB.
1777
+ """
1778
+ rows: List[Dict[str, Any]] = []
1779
+ for pn in range(1, 4): # up to 3 pages × 100 = 300 movers
1780
+ data = self._em_get_json(self.EM_HOT_URL, {
1781
+ "pn": pn, "pz": 100, "po": 1, "np": 1, "ut": _EM_UT,
1782
+ "fltt": 2, "invt": 2, "fid": "f3", # sort by change% desc
1783
+ "fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048",
1784
+ "fields": "f2,f3,f8,f9,f12,f14,f20,f23",
1785
+ })
1786
+ if not data:
1787
+ break
1788
+ diff = (data.get("data") or {}).get("diff", []) or []
1789
+ if isinstance(diff, dict):
1790
+ diff = list(diff.values())
1791
+ if not diff:
1792
+ break
1793
+ rows.extend(diff)
1794
+ if len(diff) < 100:
1795
+ break
1796
+
1797
+ if not rows:
1798
+ return {"success": False, "error": "eastmoney 无响应(网络或代理)"}
1799
+
1800
+ def _num(v):
1801
+ try:
1802
+ return float(v)
1803
+ except (ValueError, TypeError):
1804
+ return None
1805
+
1806
+ out: List[Dict[str, Any]] = []
1807
+ for d in rows:
1808
+ name = str(d.get("f14", ""))
1809
+ if exclude_st and ("ST" in name or "退" in name):
1810
+ continue
1811
+ price = _num(d.get("f2"))
1812
+ pe = _num(d.get("f9"))
1813
+ mktcap = _num(d.get("f20")) # 元
1814
+ if price is None or price <= 0:
1815
+ continue
1816
+ if pe is not None and not (0.1 <= pe <= max_pe):
1817
+ continue
1818
+ if min_market_cap_yi > 0 and (mktcap is None or mktcap < min_market_cap_yi * 1e8):
1819
+ continue
1820
+ out.append({
1821
+ "code": str(d.get("f12", "")),
1822
+ "name": name,
1823
+ "price": round(price, 2),
1824
+ "change_pct": round(_num(d.get("f3")) or 0, 2),
1825
+ "pe_dynamic": round(pe, 1) if pe is not None else None,
1826
+ "pb": round(_num(d.get("f23")) or 0, 2),
1827
+ "turnover_rate": round(_num(d.get("f8")) or 0, 2),
1828
+ "market_cap_yi": round(mktcap / 1e8, 1) if mktcap else None,
1829
+ })
1830
+ if len(out) >= limit:
1831
+ break
1832
+
1833
+ return {"success": True, "count": len(out), "stocks": out,
1834
+ "provider": "eastmoney"}
1835
+
1836
+ def _fetch_hot_us(self, top_n: int = 10) -> Dict[str, Any]:
1837
+ """US most active stocks via yfinance screener."""
1838
+ watchlist = ["NVDA","AAPL","TSLA","MSFT","AMZN","META","GOOGL","AMD","INTC","PLTR"]
1839
+ results = []
1840
+ try:
1841
+ import yfinance as yf
1842
+ for sym in watchlist[:top_n]:
1843
+ try:
1844
+ fi = yf.Ticker(sym).fast_info
1845
+ p = float(fi.last_price or 0)
1846
+ prev = float(fi.previous_close or p)
1847
+ chg_p = (p-prev)/prev*100 if prev else 0
1848
+ results.append({"symbol": sym, "price": round(p,2),
1849
+ "change_pct": round(chg_p,2)})
1850
+ except Exception as _e:
1851
+ logger.debug("Screener quote failed for %s: %s", sym, _e)
1852
+ return {"success": True, "market": "US", "stocks": results,
1853
+ "count": len(results), "provider": "yfinance"}
1854
+ except Exception as e:
1855
+ return {"success": False, "error": str(e)}
1856
+
1857
+
1858
+ # ── Module-level singleton ───────────────────────────────────────────────────
1859
+
1860
+ _mdc: Optional[MarketDataClient] = None
1861
+
1862
+ def get_mdc() -> MarketDataClient:
1863
+ global _mdc
1864
+ if _mdc is None:
1865
+ _mdc = MarketDataClient()
1866
+ return _mdc
1867
+
1868
+
1869
+ # ── Convenience functions (module-level API) ─────────────────────────────────
1870
+
1871
+ def quote(symbol: str) -> Dict[str, Any]:
1872
+ return get_mdc().quote(symbol)
1873
+
1874
+ def history(symbol: str, days: int = 252, interval: str = "1d") -> Dict[str, Any]:
1875
+ return get_mdc().history(symbol, days=days, interval=interval)
1876
+
1877
+ def indices() -> Dict[str, Any]:
1878
+ return get_mdc().indices()
1879
+
1880
+ def northbound_flow() -> Dict[str, Any]:
1881
+ return get_mdc().northbound_flow()
1882
+
1883
+ def technical_indicators(symbol: str, days: int = 120) -> Dict[str, Any]:
1884
+ return get_mdc().technical_indicators(symbol, days=days)
1885
+
1886
+ def fundamentals(symbol: str) -> Dict[str, Any]:
1887
+ return get_mdc().fundamentals(symbol)
1888
+
1889
+ def hot_stocks(market: str = "cn", top_n: int = 20) -> Dict[str, Any]:
1890
+ return get_mdc().hot_stocks(market=market, top_n=top_n)
1891
+
1892
+ def screen_ashare(**kwargs) -> Dict[str, Any]:
1893
+ return get_mdc().screen_ashare(**kwargs)
1894
+
1895
+
1896
+ if __name__ == "__main__":
1897
+ import json, sys
1898
+ sym = sys.argv[1] if len(sys.argv) > 1 else "NVDA"
1899
+ print(json.dumps(quote(sym), indent=2, ensure_ascii=False, default=str))