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