aria-code 0.52.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (478) hide show
  1. aria_code/__init__.py +42 -0
  2. aria_code/_version.py +20 -0
  3. aria_code/adk_apps/__init__.py +1 -0
  4. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  5. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  6. aria_code/adk_apps/aria_research/__init__.py +1 -0
  7. aria_code/adk_apps/aria_research/agent.py +31 -0
  8. aria_code/agents/__init__.py +34 -0
  9. aria_code/agents/base.py +318 -0
  10. aria_code/agents/code_review.py +200 -0
  11. aria_code/agents/deep/__init__.py +37 -0
  12. aria_code/agents/deep/calibration_loop.py +144 -0
  13. aria_code/agents/deep/critic.py +125 -0
  14. aria_code/agents/deep/deepen.py +193 -0
  15. aria_code/agents/deep/models.py +149 -0
  16. aria_code/agents/deep/pipeline.py +164 -0
  17. aria_code/agents/deep/quant_fusion.py +192 -0
  18. aria_code/agents/deep/themes.py +95 -0
  19. aria_code/agents/deep/tiers.py +106 -0
  20. aria_code/agents/engineering/__init__.py +14 -0
  21. aria_code/agents/engineering/coder.py +222 -0
  22. aria_code/agents/engineering/tester.py +145 -0
  23. aria_code/agents/financial/__init__.py +21 -0
  24. aria_code/agents/financial/cashflow_burn.py +119 -0
  25. aria_code/agents/financial/catalyst.py +279 -0
  26. aria_code/agents/financial/corporate_finance.py +175 -0
  27. aria_code/agents/financial/debate.py +224 -0
  28. aria_code/agents/financial/earnings.py +303 -0
  29. aria_code/agents/financial/fundamental.py +191 -0
  30. aria_code/agents/financial/macro.py +99 -0
  31. aria_code/agents/financial/news.py +207 -0
  32. aria_code/agents/financial/risk.py +165 -0
  33. aria_code/agents/financial/sector.py +279 -0
  34. aria_code/agents/financial/strategist.py +121 -0
  35. aria_code/agents/financial/stripe_revenue.py +127 -0
  36. aria_code/agents/financial/synthesis.py +274 -0
  37. aria_code/agents/financial/technical.py +258 -0
  38. aria_code/agents/orchestrator.py +44 -0
  39. aria_code/agents/portfolio_agent.py +363 -0
  40. aria_code/agents/realty/__init__.py +62 -0
  41. aria_code/agents/realty/asset_diagnosis.py +152 -0
  42. aria_code/agents/realty/business_match.py +166 -0
  43. aria_code/agents/realty/cashflow_verify.py +209 -0
  44. aria_code/agents/realty/contract_rules.py +210 -0
  45. aria_code/agents/realty/energy_anomaly.py +189 -0
  46. aria_code/agents/realty/exit_settlement.py +208 -0
  47. aria_code/agents/realty/fulfillment_risk.py +206 -0
  48. aria_code/agents/realty/ops_optimize.py +160 -0
  49. aria_code/agents/realty/revenue_share.py +215 -0
  50. aria_code/agents/registry.py +158 -0
  51. aria_code/agents/signal_scheme.py +111 -0
  52. aria_code/agents/sports/__init__.py +0 -0
  53. aria_code/agents/sports/football_agent.py +197 -0
  54. aria_code/agents/supervisor.py +113 -0
  55. aria_code/agents/team.py +492 -0
  56. aria_code/agents/warehouse/__init__.py +25 -0
  57. aria_code/agents/warehouse/contracts.py +26 -0
  58. aria_code/agents/warehouse/dashboard.py +155 -0
  59. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  60. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  61. aria_code/agents/warehouse/inventory_health.py +24 -0
  62. aria_code/agents/warehouse/local_run.py +71 -0
  63. aria_code/agents/warehouse/local_snapshot.py +132 -0
  64. aria_code/agents/warehouse/logistics_cost.py +84 -0
  65. aria_code/agents/warehouse/logistics_sync.py +21 -0
  66. aria_code/agents/warehouse/workflow.py +31 -0
  67. aria_code/aliyun_data_client.py +4 -0
  68. aria_code/apps/README.md +12 -0
  69. aria_code/apps/__init__.py +2 -0
  70. aria_code/apps/channels/README.md +15 -0
  71. aria_code/apps/channels/__init__.py +17 -0
  72. aria_code/apps/channels/intake.py +92 -0
  73. aria_code/apps/channels/registry.py +95 -0
  74. aria_code/apps/channels/tradingview.py +94 -0
  75. aria_code/apps/cli/README.md +13 -0
  76. aria_code/apps/cli/__init__.py +2 -0
  77. aria_code/apps/cli/_optional.py +120 -0
  78. aria_code/apps/cli/bootstrap.py +145 -0
  79. aria_code/apps/cli/broker_render.py +206 -0
  80. aria_code/apps/cli/codegen_paths.py +29 -0
  81. aria_code/apps/cli/commands/__init__.py +16 -0
  82. aria_code/apps/cli/commands/_business_render.py +78 -0
  83. aria_code/apps/cli/commands/_ui.py +71 -0
  84. aria_code/apps/cli/commands/analysis_cmds.py +369 -0
  85. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  86. aria_code/apps/cli/commands/auth_cmds.py +239 -0
  87. aria_code/apps/cli/commands/backtest_cmds.py +2543 -0
  88. aria_code/apps/cli/commands/broker_cmds.py +1234 -0
  89. aria_code/apps/cli/commands/business_workflow_cmds.py +319 -0
  90. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  91. aria_code/apps/cli/commands/catalog.py +87 -0
  92. aria_code/apps/cli/commands/core_cmds.py +2520 -0
  93. aria_code/apps/cli/commands/data_cmds.py +457 -0
  94. aria_code/apps/cli/commands/diagnostic_cmds.py +230 -0
  95. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  96. aria_code/apps/cli/commands/file_cmds.py +312 -0
  97. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  98. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  99. aria_code/apps/cli/commands/market.py +434 -0
  100. aria_code/apps/cli/commands/market_cmds.py +1381 -0
  101. aria_code/apps/cli/commands/market_context.py +425 -0
  102. aria_code/apps/cli/commands/market_render.py +7 -0
  103. aria_code/apps/cli/commands/model_cmds.py +2153 -0
  104. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  105. aria_code/apps/cli/commands/orchestrator_cmds.py +179 -0
  106. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  107. aria_code/apps/cli/commands/portfolio_cmds.py +1278 -0
  108. aria_code/apps/cli/commands/report.py +437 -0
  109. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  110. aria_code/apps/cli/commands/session_cmds.py +236 -0
  111. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  112. aria_code/apps/cli/commands/team.py +669 -0
  113. aria_code/apps/cli/commands/team_render.py +8 -0
  114. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  115. aria_code/apps/cli/commands/warehouse_cmds.py +79 -0
  116. aria_code/apps/cli/commands/workflow_cmds.py +480 -0
  117. aria_code/apps/cli/commands/workspace_cmds.py +1494 -0
  118. aria_code/apps/cli/config_paths.py +73 -0
  119. aria_code/apps/cli/config_store.py +78 -0
  120. aria_code/apps/cli/constants.py +88 -0
  121. aria_code/apps/cli/context.py +34 -0
  122. aria_code/apps/cli/deterministic.py +191 -0
  123. aria_code/apps/cli/direct.py +48 -0
  124. aria_code/apps/cli/finance_service_catalog.py +111 -0
  125. aria_code/apps/cli/football_reports.py +231 -0
  126. aria_code/apps/cli/github_app_auth.py +135 -0
  127. aria_code/apps/cli/google_login.py +196 -0
  128. aria_code/apps/cli/handlers/__init__.py +11 -0
  129. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  130. aria_code/apps/cli/handlers/chart_handlers.py +1309 -0
  131. aria_code/apps/cli/handlers/market_handlers.py +2513 -0
  132. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  133. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  134. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  135. aria_code/apps/cli/helpers.py +302 -0
  136. aria_code/apps/cli/hooks.py +180 -0
  137. aria_code/apps/cli/i18n.py +297 -0
  138. aria_code/apps/cli/intent.py +136 -0
  139. aria_code/apps/cli/intent_router.py +276 -0
  140. aria_code/apps/cli/intent_signals.py +344 -0
  141. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  142. aria_code/apps/cli/local_tool_schemas.py +536 -0
  143. aria_code/apps/cli/main.py +29 -0
  144. aria_code/apps/cli/market_metadata.py +135 -0
  145. aria_code/apps/cli/market_universe.py +487 -0
  146. aria_code/apps/cli/message_processing.py +310 -0
  147. aria_code/apps/cli/model_catalog.py +620 -0
  148. aria_code/apps/cli/pdf_report.py +977 -0
  149. aria_code/apps/cli/plan_mode.py +139 -0
  150. aria_code/apps/cli/plotly_html.py +15 -0
  151. aria_code/apps/cli/prediction_feedback.py +202 -0
  152. aria_code/apps/cli/preflight.py +502 -0
  153. aria_code/apps/cli/project_aria.py +60 -0
  154. aria_code/apps/cli/prompt_assembly.py +59 -0
  155. aria_code/apps/cli/prompts/__init__.py +0 -0
  156. aria_code/apps/cli/prompts/coding.py +53 -0
  157. aria_code/apps/cli/prompts/select.py +142 -0
  158. aria_code/apps/cli/prompts/system_prompts.py +552 -0
  159. aria_code/apps/cli/prompts/ui.py +436 -0
  160. aria_code/apps/cli/provider_endpoints.py +237 -0
  161. aria_code/apps/cli/provider_keys.py +73 -0
  162. aria_code/apps/cli/providers/__init__.py +1 -0
  163. aria_code/apps/cli/providers/base.py +521 -0
  164. aria_code/apps/cli/providers/chat_routing.py +128 -0
  165. aria_code/apps/cli/providers/collaboration.py +98 -0
  166. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  167. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  168. aria_code/apps/cli/providers/llm/ollama_stream.py +1594 -0
  169. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  170. aria_code/apps/cli/providers/llm/sse_stream.py +257 -0
  171. aria_code/apps/cli/providers/runtime_bridge.py +353 -0
  172. aria_code/apps/cli/providers/vertexai_stream.py +316 -0
  173. aria_code/apps/cli/runtime_consumer.py +657 -0
  174. aria_code/apps/cli/sandbox.py +32 -0
  175. aria_code/apps/cli/session_export.py +120 -0
  176. aria_code/apps/cli/session_jsonl.py +208 -0
  177. aria_code/apps/cli/session_store.py +112 -0
  178. aria_code/apps/cli/skills_catalog.py +273 -0
  179. aria_code/apps/cli/todo_tracker.py +190 -0
  180. aria_code/apps/cli/tool_executor.py +823 -0
  181. aria_code/apps/cli/tool_registry.py +34 -0
  182. aria_code/apps/cli/tool_scope.py +106 -0
  183. aria_code/apps/cli/tools/__init__.py +40 -0
  184. aria_code/apps/cli/tools/context.py +46 -0
  185. aria_code/apps/cli/tools/file_tools.py +180 -0
  186. aria_code/apps/cli/tools/market_tools.py +620 -0
  187. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  188. aria_code/apps/cli/tools/system_tools.py +726 -0
  189. aria_code/apps/cli/tools/write_tools.py +845 -0
  190. aria_code/apps/cli/tradingview_bridge.py +529 -0
  191. aria_code/apps/cli/turn_planning.py +137 -0
  192. aria_code/apps/cli/update_check.py +153 -0
  193. aria_code/apps/cli/utils/__init__.py +0 -0
  194. aria_code/apps/cli/utils/market_detect.py +1597 -0
  195. aria_code/apps/daemon/README.md +14 -0
  196. aria_code/apps/vscode/README.md +115 -0
  197. aria_code/apps/vscode/package.json +70 -0
  198. aria_code/aria_cli.py +6743 -0
  199. aria_code/aria_daemon.py +1398 -0
  200. aria_code/aria_feishu_bot.py +1423 -0
  201. aria_code/aria_mcp_server.py +7 -0
  202. aria_code/aria_relay_client.py +4 -0
  203. aria_code/aria_relay_server.py +490 -0
  204. aria_code/aria_telegram_bot.py +202 -0
  205. aria_code/ariarc.py +385 -0
  206. aria_code/artifacts.py +581 -0
  207. aria_code/backtest_engine.py +4 -0
  208. aria_code/backtest_report.py +4 -0
  209. aria_code/brokers/__init__.py +78 -0
  210. aria_code/brokers/automation.py +193 -0
  211. aria_code/brokers/base.py +231 -0
  212. aria_code/brokers/capabilities.py +264 -0
  213. aria_code/brokers/cn/__init__.py +10 -0
  214. aria_code/brokers/cn/easytrader_broker.py +193 -0
  215. aria_code/brokers/cn/futu_broker.py +194 -0
  216. aria_code/brokers/cn/longbridge_broker.py +190 -0
  217. aria_code/brokers/cn/tiger_broker.py +196 -0
  218. aria_code/brokers/cn/xtquant_broker.py +225 -0
  219. aria_code/brokers/config.py +414 -0
  220. aria_code/brokers/intl/__init__.py +5 -0
  221. aria_code/brokers/intl/alpaca_broker.py +183 -0
  222. aria_code/brokers/intl/ibkr_broker.py +215 -0
  223. aria_code/brokers/intl/trading212_broker.py +183 -0
  224. aria_code/brokers/intl/webull_broker.py +156 -0
  225. aria_code/brokers/paper_broker.py +266 -0
  226. aria_code/brokers/planning.py +296 -0
  227. aria_code/brokers/registry.py +213 -0
  228. aria_code/brokers/trading.py +267 -0
  229. aria_code/canva_client.py +4 -0
  230. aria_code/change_store.py +127 -0
  231. aria_code/clients/__init__.py +0 -0
  232. aria_code/clients/aliyun_data_client.py +660 -0
  233. aria_code/clients/aria_relay_client.py +182 -0
  234. aria_code/clients/canva_client.py +284 -0
  235. aria_code/clients/figma_client.py +100 -0
  236. aria_code/clients/football_data_client.py +1671 -0
  237. aria_code/clients/kling_video_client.py +182 -0
  238. aria_code/clients/market_data_client.py +1976 -0
  239. aria_code/clients/openai_image_client.py +242 -0
  240. aria_code/clients/runway_video_client.py +157 -0
  241. aria_code/clients/warehouse_erp_client.py +105 -0
  242. aria_code/cloud_memory.py +181 -0
  243. aria_code/command_safety.py +19 -0
  244. aria_code/computer_use_tools.py +4 -0
  245. aria_code/dashboard_generator.py +579 -0
  246. aria_code/data_analysis_tools.py +4 -0
  247. aria_code/data_cleaner.py +483 -0
  248. aria_code/data_service.py +513 -0
  249. aria_code/datasources/__init__.py +23 -0
  250. aria_code/datasources/base.py +166 -0
  251. aria_code/datasources/router.py +253 -0
  252. aria_code/datasources/sources/__init__.py +15 -0
  253. aria_code/datasources/sources/akshare_source.py +269 -0
  254. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  255. aria_code/datasources/sources/edgar_source.py +219 -0
  256. aria_code/datasources/sources/finnhub_source.py +198 -0
  257. aria_code/datasources/sources/fred_source.py +223 -0
  258. aria_code/datasources/sources/tushare_source.py +143 -0
  259. aria_code/datasources/sources/web_scraper_source.py +278 -0
  260. aria_code/datasources/sources/world_bank_source.py +206 -0
  261. aria_code/datasources/sources/yfinance_source.py +152 -0
  262. aria_code/demo_player.py +207 -0
  263. aria_code/doctor.py +813 -0
  264. aria_code/domain/__init__.py +0 -0
  265. aria_code/domain/backtest_engine.py +354 -0
  266. aria_code/domain/backtest_report.py +472 -0
  267. aria_code/domain/finance_formulas.py +389 -0
  268. aria_code/domain/portfolio_ledger.py +307 -0
  269. aria_code/domain/strategy_vault.py +421 -0
  270. aria_code/evals/__init__.py +37 -0
  271. aria_code/evals/harness.py +598 -0
  272. aria_code/evals/runner.py +238 -0
  273. aria_code/external_agent_runner.py +115 -0
  274. aria_code/figma_client.py +4 -0
  275. aria_code/file_analysis_tools.py +4 -0
  276. aria_code/finance_formulas.py +4 -0
  277. aria_code/football_data_client.py +4 -0
  278. aria_code/image_gen_tools.py +4 -0
  279. aria_code/intent_classifier.py +386 -0
  280. aria_code/kling_video_client.py +4 -0
  281. aria_code/licensing.py +151 -0
  282. aria_code/local_finance_tools.py +4 -0
  283. aria_code/local_image_provider.py +4 -0
  284. aria_code/local_llm_provider.py +4 -0
  285. aria_code/macro_tools.py +4 -0
  286. aria_code/markdown_pdf.py +289 -0
  287. aria_code/market_data_client.py +4 -0
  288. aria_code/mcp_client.py +639 -0
  289. aria_code/memory_manager.py +246 -0
  290. aria_code/model_capability.py +486 -0
  291. aria_code/notification_tools.py +4 -0
  292. aria_code/openai_image_client.py +4 -0
  293. aria_code/packages/__init__.py +23 -0
  294. aria_code/packages/adk_bridge/__init__.py +11 -0
  295. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  296. aria_code/packages/adk_bridge/market_tools.py +125 -0
  297. aria_code/packages/aria_agents/__init__.py +5 -0
  298. aria_code/packages/aria_agents/manifest.py +78 -0
  299. aria_code/packages/aria_core/__init__.py +34 -0
  300. aria_code/packages/aria_core/architecture.py +269 -0
  301. aria_code/packages/aria_core/export.py +147 -0
  302. aria_code/packages/aria_core/manifest.py +65 -0
  303. aria_code/packages/aria_core/paths.py +57 -0
  304. aria_code/packages/aria_core/secure_file.py +68 -0
  305. aria_code/packages/aria_infra/__init__.py +15 -0
  306. aria_code/packages/aria_infra/arthera.py +52 -0
  307. aria_code/packages/aria_infra/doctor.py +246 -0
  308. aria_code/packages/aria_infra/product.py +40 -0
  309. aria_code/packages/aria_mcp/__init__.py +25 -0
  310. aria_code/packages/aria_mcp/bridge.py +201 -0
  311. aria_code/packages/aria_mcp/circuit.py +82 -0
  312. aria_code/packages/aria_mcp/config.py +111 -0
  313. aria_code/packages/aria_mcp/installer.py +95 -0
  314. aria_code/packages/aria_mcp/server.py +1550 -0
  315. aria_code/packages/aria_mcp/tools.py +81 -0
  316. aria_code/packages/aria_sdk/__init__.py +19 -0
  317. aria_code/packages/aria_sdk/client.py +396 -0
  318. aria_code/packages/aria_sdk/providers.py +70 -0
  319. aria_code/packages/aria_sdk/streaming.py +79 -0
  320. aria_code/packages/aria_sdk/types.py +86 -0
  321. aria_code/packages/aria_services/__init__.py +92 -0
  322. aria_code/packages/aria_services/cache.py +60 -0
  323. aria_code/packages/aria_services/context.py +356 -0
  324. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  325. aria_code/packages/aria_services/data.py +11 -0
  326. aria_code/packages/aria_services/provider_health.py +268 -0
  327. aria_code/packages/aria_services/references.py +247 -0
  328. aria_code/packages/aria_services/registry.py +249 -0
  329. aria_code/packages/aria_services/research_protocol.py +57 -0
  330. aria_code/packages/aria_services/research_quality.py +186 -0
  331. aria_code/packages/aria_services/settings.py +157 -0
  332. aria_code/packages/aria_services/usage.py +151 -0
  333. aria_code/packages/aria_skills/__init__.py +46 -0
  334. aria_code/packages/aria_skills/catalog.py +149 -0
  335. aria_code/packages/aria_skills/loader.py +455 -0
  336. aria_code/packages/aria_skills/registry.py +84 -0
  337. aria_code/packages/aria_tools/__init__.py +5 -0
  338. aria_code/packages/aria_tools/financial/__init__.py +8 -0
  339. aria_code/packages/aria_tools/financial/_paths.py +130 -0
  340. aria_code/packages/aria_tools/financial/compliance.py +31 -0
  341. aria_code/packages/aria_tools/financial/factors.py +33 -0
  342. aria_code/packages/aria_tools/financial/risk_tools.py +33 -0
  343. aria_code/packages/aria_tools/financial/strategy.py +31 -0
  344. aria_code/packages/aria_tools/registry.py +128 -0
  345. aria_code/packages/quant_engine/__init__.py +34 -0
  346. aria_code/packages/quant_engine/_namespace.py +62 -0
  347. aria_code/packages/quant_engine/backtest/__init__.py +14 -0
  348. aria_code/packages/quant_engine/backtest/core.py +53 -0
  349. aria_code/packages/quant_engine/backtest/engine.py +96 -0
  350. aria_code/packages/quant_engine/portfolio/__init__.py +13 -0
  351. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  352. aria_code/packages/quant_engine/sports/__init__.py +80 -0
  353. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  354. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  355. aria_code/packages/quant_engine/sports/elo.py +300 -0
  356. aria_code/packages/quant_engine/sports/form.py +188 -0
  357. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  358. aria_code/packages/quant_engine/sports/ml_model.py +377 -0
  359. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  360. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  361. aria_code/packages/quant_engine/stochastic/__init__.py +35 -0
  362. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  363. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  364. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  365. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  366. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  367. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  368. aria_code/packs/__init__.py +85 -0
  369. aria_code/packs/base.py +168 -0
  370. aria_code/packs/finance/__init__.py +250 -0
  371. aria_code/packs/logistics/__init__.py +169 -0
  372. aria_code/packs/payments/__init__.py +139 -0
  373. aria_code/packs/realty/__init__.py +190 -0
  374. aria_code/packs/registry.py +202 -0
  375. aria_code/packs/rules.py +90 -0
  376. aria_code/plan_utils.py +194 -0
  377. aria_code/plugin_loader.py +328 -0
  378. aria_code/portfolio_ledger.py +4 -0
  379. aria_code/preview_server.py +449 -0
  380. aria_code/privacy/__init__.py +5 -0
  381. aria_code/privacy/feedback.py +123 -0
  382. aria_code/project_review.py +278 -0
  383. aria_code/project_review_api.py +235 -0
  384. aria_code/project_review_server.py +18 -0
  385. aria_code/project_tools.py +4 -0
  386. aria_code/providers/__init__.py +30 -0
  387. aria_code/providers/llm/__init__.py +19 -0
  388. aria_code/providers/llm/anthropic.py +184 -0
  389. aria_code/providers/llm/autoconfig.py +213 -0
  390. aria_code/providers/llm/base.py +139 -0
  391. aria_code/providers/llm/custom.py +158 -0
  392. aria_code/providers/llm/ollama.py +129 -0
  393. aria_code/providers/llm/openai_compat.py +313 -0
  394. aria_code/providers/llm/registry.py +444 -0
  395. aria_code/providers/local_image_provider.py +202 -0
  396. aria_code/providers/local_llm_provider.py +589 -0
  397. aria_code/realty_data_tools.py +4 -0
  398. aria_code/report_exporters.py +150 -0
  399. aria_code/report_generator.py +1764 -0
  400. aria_code/runtime/__init__.py +169 -0
  401. aria_code/runtime/acceptance.py +481 -0
  402. aria_code/runtime/agent_loop.py +1520 -0
  403. aria_code/runtime/approval.py +57 -0
  404. aria_code/runtime/budget.py +232 -0
  405. aria_code/runtime/checkpoints.py +432 -0
  406. aria_code/runtime/crash_report.py +174 -0
  407. aria_code/runtime/events.py +113 -0
  408. aria_code/runtime/gateway.py +150 -0
  409. aria_code/runtime/lsp.py +484 -0
  410. aria_code/runtime/orchestrator.py +55 -0
  411. aria_code/runtime/repo_map.py +700 -0
  412. aria_code/runtime/run_state.py +127 -0
  413. aria_code/runtime/run_store.py +450 -0
  414. aria_code/runtime/self_healing.py +422 -0
  415. aria_code/runtime/subagent.py +547 -0
  416. aria_code/runtime/task_ledger.py +86 -0
  417. aria_code/runtime/task_orchestrator.py +90 -0
  418. aria_code/runtime/tool_executor.py +217 -0
  419. aria_code/runtime/tool_policy.py +107 -0
  420. aria_code/runtime/worktrees.py +215 -0
  421. aria_code/runway_video_client.py +4 -0
  422. aria_code/safety/__init__.py +23 -0
  423. aria_code/safety/permissions.py +306 -0
  424. aria_code/safety/service.py +102 -0
  425. aria_code/setup_wizard.py +673 -0
  426. aria_code/spreadsheet_tools.py +4 -0
  427. aria_code/strategy_vault.py +4 -0
  428. aria_code/tools/__init__.py +0 -0
  429. aria_code/tools/artifact_tools.py +252 -0
  430. aria_code/tools/broker_tools.py +92 -0
  431. aria_code/tools/build_quant_engine.py +97 -0
  432. aria_code/tools/code_audit_tools.py +192 -0
  433. aria_code/tools/computer_use_tools.py +504 -0
  434. aria_code/tools/data_analysis_tools.py +809 -0
  435. aria_code/tools/enterprise_finance_tools.py +156 -0
  436. aria_code/tools/extended_tools.py +240 -0
  437. aria_code/tools/file_analysis_tools.py +885 -0
  438. aria_code/tools/image_gen_tools.py +114 -0
  439. aria_code/tools/local_finance_tools.py +3417 -0
  440. aria_code/tools/logistics_tools.py +135 -0
  441. aria_code/tools/macro_tools.py +369 -0
  442. aria_code/tools/notification_tools.py +249 -0
  443. aria_code/tools/project_tools.py +525 -0
  444. aria_code/tools/realty_data_tools.py +659 -0
  445. aria_code/tools/spreadsheet_tools.py +388 -0
  446. aria_code/tools/stripe_tools.py +115 -0
  447. aria_code/ui/__init__.py +105 -0
  448. aria_code/ui/banner.py +508 -0
  449. aria_code/ui/completer.py +501 -0
  450. aria_code/ui/console.py +281 -0
  451. aria_code/ui/image_render.py +243 -0
  452. aria_code/ui/input_box.py +488 -0
  453. aria_code/ui/picker.py +201 -0
  454. aria_code/ui/render/__init__.py +11 -0
  455. aria_code/ui/render/finance.py +1705 -0
  456. aria_code/ui/render/market.py +225 -0
  457. aria_code/ui/render/output.py +775 -0
  458. aria_code/ui/render/responsive.py +75 -0
  459. aria_code/ui/render/team.py +354 -0
  460. aria_code/ui/robot.py +235 -0
  461. aria_code/ui/startup_dashboard.py +109 -0
  462. aria_code/utils/vscode_uri.py +37 -0
  463. aria_code/video_analysis.py +147 -0
  464. aria_code/video_editor.py +271 -0
  465. aria_code/workspace/__init__.py +6 -0
  466. aria_code/workspace/files.py +193 -0
  467. aria_code/workspace/verify.py +113 -0
  468. aria_code/workspace_changes.py +199 -0
  469. aria_code/workspace_context.py +233 -0
  470. aria_code/workspace_service.py +196 -0
  471. aria_code/workspace_tools.py +150 -0
  472. aria_code-0.52.0.dist-info/METADATA +237 -0
  473. aria_code-0.52.0.dist-info/RECORD +478 -0
  474. aria_code-0.52.0.dist-info/WHEEL +5 -0
  475. aria_code-0.52.0.dist-info/entry_points.txt +3 -0
  476. aria_code-0.52.0.dist-info/licenses/LICENSE +201 -0
  477. aria_code-0.52.0.dist-info/licenses/NOTICE +49 -0
  478. aria_code-0.52.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3417 @@
1
+ """
2
+ local_finance_tools.py — Fully-offline financial tool implementations for Aria Code.
3
+
4
+ Purpose
5
+ -------
6
+ When running in local_mode (no Arthera backend), this module provides drop-in
7
+ replacements for every ARIA_TOOLS entry using open-source data libraries:
8
+
9
+ A股 data → akshare
10
+ US/Global → yfinance
11
+ Crypto → ccxt
12
+ Backtesting → vectorbt (or pandas fallback)
13
+ Technical → pandas_ta (or ta-lib if installed)
14
+ Risk → scipy / numpy
15
+
16
+ Each tool follows the same contract as the remote Aria tools:
17
+ handler(params: dict) -> dict (always returns a dict, never raises)
18
+
19
+ Registration
20
+ ------------
21
+ Call ``register_local_finance_tools(LOCAL_TOOLS, LOCAL_TOOL_SCHEMAS)`` at
22
+ startup to extend the CLI's tool registry. The function is idempotent.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import traceback
31
+ from importlib.util import find_spec
32
+ from datetime import datetime, timedelta
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+ from aria_code.packages.aria_core.paths import aria_home
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Alibaba Cloud data client (optional — degrades gracefully when offline)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ try:
46
+ from aria_code.aliyun_data_client import (
47
+ AliyunDataClient,
48
+ cloud_get_quote_sync,
49
+ cloud_get_history_sync,
50
+ cloud_get_factors_sync,
51
+ cloud_get_ai_signal_sync,
52
+ )
53
+ _HAS_CLOUD = True
54
+ logger.debug("Alibaba Cloud data client loaded ✓")
55
+ except ImportError:
56
+ _HAS_CLOUD = False
57
+ logger.debug("aliyun_data_client not found — cloud features disabled")
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Optional dependency guards
61
+ # ---------------------------------------------------------------------------
62
+
63
+ try:
64
+ import yfinance as yf
65
+ _HAS_YF = True
66
+ except ImportError:
67
+ _HAS_YF = False
68
+
69
+ try:
70
+ import akshare as ak
71
+ _HAS_AK = True
72
+ except ImportError:
73
+ _HAS_AK = False
74
+
75
+
76
+ def _ak_retry(fn, *args, _tries: int = 3, _delay: float = 0.8, **kwargs):
77
+ """Call an akshare function with retries, bypassing a broken proxy.
78
+
79
+ Two transient failure modes are handled:
80
+ 1. akshare hits numbered eastmoney hosts (NN.push2.eastmoney.com) that go
81
+ down individually — a retry usually lands on a healthy host.
82
+ 2. A misconfigured HTTP(S)_PROXY raises ProxyError even though the source
83
+ is directly reachable. On a proxy/connection error we retry with the
84
+ proxy env vars temporarily cleared (akshare uses requests trust_env),
85
+ then restore them so the rest of the app is unaffected.
86
+ """
87
+ import os as _os
88
+ import time as _t
89
+ _PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy")
90
+ last_exc = None
91
+ for _i in range(_tries):
92
+ _saved = {}
93
+ _bypass = _i > 0 # first attempt honours the proxy; retries bypass it
94
+ if _bypass:
95
+ for _v in _PROXY_VARS:
96
+ if _v in _os.environ:
97
+ _saved[_v] = _os.environ.pop(_v)
98
+ try:
99
+ return fn(*args, **kwargs)
100
+ except Exception as exc: # noqa: BLE001 — akshare raises many types
101
+ last_exc = exc
102
+ if _i < _tries - 1:
103
+ _t.sleep(_delay)
104
+ finally:
105
+ for _v, _val in _saved.items():
106
+ _os.environ[_v] = _val
107
+ raise last_exc
108
+
109
+ try:
110
+ import ccxt
111
+ _HAS_CCXT = True
112
+ except ImportError:
113
+ _HAS_CCXT = False
114
+
115
+ _HAS_TA = find_spec("pandas_ta") is not None
116
+
117
+ try:
118
+ import vectorbt as vbt
119
+ _HAS_VBT = True
120
+ except ImportError:
121
+ _HAS_VBT = False
122
+
123
+ try:
124
+ from scipy import stats as sp_stats
125
+ _HAS_SCIPY = True
126
+ except ImportError:
127
+ _HAS_SCIPY = False
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Helper utilities
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def _safe(fn, params, *args, **kwargs):
135
+ """Wrap a tool handler so it never raises — returns error dict instead."""
136
+ try:
137
+ return fn(params, *args, **kwargs)
138
+ except Exception as exc:
139
+ logger.debug("Local finance tool error: %s", traceback.format_exc())
140
+ return {"success": False, "error": str(exc), "traceback": traceback.format_exc()[-500:]}
141
+
142
+
143
+ def _parse_date(s: Optional[str], default_days_back: int = 365) -> str:
144
+ if s:
145
+ return s
146
+ return (datetime.today() - timedelta(days=default_days_back)).strftime("%Y-%m-%d")
147
+
148
+
149
+ def _today() -> str:
150
+ return datetime.today().strftime("%Y-%m-%d")
151
+
152
+
153
+ def _is_ashare(symbol: str) -> bool:
154
+ s = symbol.strip().lower()
155
+ return (
156
+ s.startswith("sh") or s.startswith("sz")
157
+ or (len(s) == 6 and s.isdigit())
158
+ or s.endswith(".ss") or s.endswith(".sz")
159
+ )
160
+
161
+
162
+ def _normalise_ashare(symbol: str) -> str:
163
+ s = symbol.strip().lower().replace(".ss", "").replace(".sz", "")
164
+ s = s.replace("sh", "").replace("sz", "")
165
+ if len(s) == 6 and s.isdigit():
166
+ prefix = "sh" if s.startswith("6") else "sz"
167
+ return prefix + s
168
+ return s
169
+
170
+
171
+ def _get_pandas_ta():
172
+ if not _HAS_TA:
173
+ return None
174
+ try:
175
+ import pandas_ta as ta
176
+ except Exception:
177
+ return None
178
+ return ta
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # 1. get_market_data
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def _get_market_data(params: dict) -> dict:
186
+ symbol = params.get("symbol", "AAPL").upper()
187
+ period = params.get("period", "1y") # yfinance period
188
+ interval = params.get("interval", "1d")
189
+ start = params.get("start")
190
+ end = params.get("end", _today())
191
+
192
+ if _is_ashare(symbol):
193
+ return _get_ashare_data(symbol, start or _parse_date(None, 365), end)
194
+
195
+ # ── Try Alibaba Cloud first (for US/global symbols) ────────────────
196
+ if _HAS_CLOUD:
197
+ try:
198
+ cloud_q = cloud_get_quote_sync(symbol)
199
+ if cloud_q and cloud_q.get("price"):
200
+ return {
201
+ "success": True,
202
+ "symbol": symbol,
203
+ "latest_close": round(float(cloud_q.get("price", 0)), 4),
204
+ "change_pct": round(float(cloud_q.get("change_percent", 0)), 3),
205
+ "volume": int(cloud_q.get("volume", 0)),
206
+ "high": round(float(cloud_q.get("high", 0)), 4),
207
+ "low": round(float(cloud_q.get("low", 0)), 4),
208
+ "open": round(float(cloud_q.get("open", 0)), 4),
209
+ "name": cloud_q.get("name", symbol),
210
+ "market": cloud_q.get("market", "US"),
211
+ "provider": "aliyun_cloud",
212
+ }
213
+ except Exception as exc:
214
+ logger.debug("Cloud quote failed for %s: %s — falling back to yfinance", symbol, exc)
215
+
216
+ if not _HAS_YF:
217
+ return {"success": False, "error": "yfinance not installed: pip install yfinance"}
218
+
219
+ try:
220
+ tkr = yf.Ticker(symbol)
221
+ if start:
222
+ hist = tkr.history(start=start, end=end, interval=interval)
223
+ else:
224
+ hist = tkr.history(period=period, interval=interval)
225
+
226
+ if hist.empty:
227
+ return {"success": False, "error": f"No data for {symbol}"}
228
+
229
+ info = tkr.fast_info
230
+ latest = hist.iloc[-1]
231
+ prev = hist.iloc[-2] if len(hist) > 1 else latest
232
+ chg = (latest["Close"] - prev["Close"]) / prev["Close"] * 100
233
+
234
+ return {
235
+ "success": True,
236
+ "symbol": symbol,
237
+ "latest_close": round(float(latest["Close"]), 4),
238
+ "change_pct": round(float(chg), 3),
239
+ "volume": int(latest["Volume"]),
240
+ "high_52w": round(float(info.year_high), 4) if hasattr(info, "year_high") else None,
241
+ "low_52w": round(float(info.year_low), 4) if hasattr(info, "year_low") else None,
242
+ "market_cap": getattr(info, "market_cap", None),
243
+ "currency": getattr(info, "currency", "USD"),
244
+ "bars": len(hist),
245
+ "history_tail": _df_tail(hist, 5),
246
+ "provider": "yfinance",
247
+ }
248
+ except Exception as exc:
249
+ return {"success": False, "error": str(exc)}
250
+
251
+
252
+ def _get_ashare_data(symbol: str, start: str, end: str) -> dict:
253
+ # ── Try Alibaba Cloud akshare_data_server first ────────────────────
254
+ if _HAS_CLOUD:
255
+ try:
256
+ cloud_hist = cloud_get_history_sync(symbol, start=start, end=end)
257
+ if cloud_hist and cloud_hist.get("data"):
258
+ rows = cloud_hist["data"]
259
+ if rows:
260
+ latest = rows[-1]
261
+ prev = rows[-2] if len(rows) > 1 else latest
262
+ cl, pc = float(latest.get("close", 0)), float(prev.get("close", 0) or 0.0001)
263
+ chg = (cl - pc) / pc * 100
264
+ # Build history_tail
265
+ tail = [
266
+ {k: v for k, v in r.items() if k in ("date", "open", "high", "low", "close", "volume")}
267
+ for r in rows[-5:]
268
+ ]
269
+ return {
270
+ "success": True,
271
+ "symbol": symbol,
272
+ "latest_close": round(cl, 3),
273
+ "change_pct": round(chg, 3),
274
+ "volume": int(latest.get("volume", 0)),
275
+ "bars": len(rows),
276
+ "history_tail": tail,
277
+ "provider": "aliyun_data",
278
+ }
279
+ except Exception as exc:
280
+ logger.debug("Cloud history failed for %s: %s — falling back to akshare", symbol, exc)
281
+
282
+ if not _HAS_AK:
283
+ return {"success": False, "error": "akshare not installed: pip install akshare"}
284
+
285
+ norm = _normalise_ashare(symbol)
286
+ code = norm[2:] # strip sh/sz prefix
287
+ try:
288
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
289
+ start_date=start.replace("-", ""),
290
+ end_date=end.replace("-", ""),
291
+ adjust="qfq")
292
+ if df is None or df.empty:
293
+ return {"success": False, "error": f"No A-share data for {symbol}"}
294
+
295
+ df = df.rename(columns={
296
+ "日期": "date", "开盘": "open", "收盘": "close",
297
+ "最高": "high", "最低": "low", "成交量": "volume",
298
+ "成交额": "amount", "换手率": "turnover_rate",
299
+ })
300
+ latest = df.iloc[-1]
301
+ prev = df.iloc[-2] if len(df) > 1 else latest
302
+ chg = (latest["close"] - prev["close"]) / prev["close"] * 100
303
+
304
+ return {
305
+ "success": True,
306
+ "symbol": symbol,
307
+ "latest_close": round(float(latest["close"]), 3),
308
+ "change_pct": round(float(chg), 3),
309
+ "volume": int(latest["volume"]),
310
+ "turnover_rate": float(latest.get("turnover_rate", 0) or 0),
311
+ "bars": len(df),
312
+ "history_tail": _df_tail(df.rename(columns={"close": "Close", "volume": "Volume"}), 5),
313
+ "provider": "akshare",
314
+ }
315
+ except Exception as exc:
316
+ return {"success": False, "error": str(exc)}
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # 2. get_crypto_data
321
+ # ---------------------------------------------------------------------------
322
+
323
+ def _get_crypto_data(params: dict) -> dict:
324
+ symbol = params.get("symbol", "BTC/USDT").upper().replace("-", "/")
325
+ exchange = params.get("exchange", "binance")
326
+ timeframe = params.get("timeframe", "1d")
327
+ limit = int(params.get("limit", 100))
328
+
329
+ if not _HAS_CCXT:
330
+ # Fallback to yfinance for common crypto tickers
331
+ if _HAS_YF:
332
+ yf_sym = symbol.replace("/", "-") + ("" if symbol.endswith("USD") else "")
333
+ return _get_market_data({"symbol": yf_sym, "period": "3mo"})
334
+ return {"success": False, "error": "ccxt not installed: pip install ccxt"}
335
+
336
+ def _yf_crypto_fallback(reason: str) -> dict:
337
+ """Fall back to yfinance when the exchange is unreachable (region block, etc.)."""
338
+ if not _HAS_YF:
339
+ return {"success": False, "error": reason}
340
+ # BTC/USDT → BTC-USD ; strip stablecoin quote to USD for yfinance
341
+ base = symbol.split("/")[0]
342
+ yf_sym = f"{base}-USD"
343
+ res = _get_market_data({"symbol": yf_sym, "period": "3mo"})
344
+ if res.get("success"):
345
+ res["provider"] = "yfinance (ccxt fallback)"
346
+ res["note"] = f"{exchange} 不可用,已回退 yfinance: {reason[:60]}"
347
+ return res
348
+
349
+ try:
350
+ ex_class = getattr(ccxt, exchange.lower(), None)
351
+ if ex_class is None:
352
+ return {"success": False, "error": f"Unknown exchange: {exchange}"}
353
+ ex = ex_class({"enableRateLimit": True})
354
+ try:
355
+ ohlcv = ex.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
356
+ except Exception as net_exc:
357
+ # Network / region block (e.g. Binance 451) → yfinance fallback
358
+ return _yf_crypto_fallback(str(net_exc))
359
+ if not ohlcv:
360
+ return _yf_crypto_fallback("Empty OHLCV data")
361
+
362
+ df = pd.DataFrame(ohlcv, columns=["ts", "open", "high", "low", "close", "volume"])
363
+ df["date"] = pd.to_datetime(df["ts"], unit="ms")
364
+ latest = df.iloc[-1]
365
+ prev = df.iloc[-2]
366
+ chg = (latest["close"] - prev["close"]) / prev["close"] * 100
367
+ vol_avg = df["volume"].tail(20).mean()
368
+
369
+ # Ticker for bid/ask
370
+ try:
371
+ ticker = ex.fetch_ticker(symbol)
372
+ except Exception:
373
+ ticker = {}
374
+
375
+ return {
376
+ "success": True,
377
+ "symbol": symbol,
378
+ "exchange": exchange,
379
+ "latest_close": round(float(latest["close"]), 6),
380
+ "change_pct_24h": round(float(chg), 3),
381
+ "volume_24h": float(latest["volume"]),
382
+ "volume_avg_20d": round(float(vol_avg), 2),
383
+ "bid": ticker.get("bid"),
384
+ "ask": ticker.get("ask"),
385
+ "bars": len(df),
386
+ "provider": "ccxt",
387
+ }
388
+ except Exception as exc:
389
+ return _yf_crypto_fallback(str(exc))
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # 3. get_forex_data
394
+ # ---------------------------------------------------------------------------
395
+
396
+ def _get_forex_data(params: dict) -> dict:
397
+ pair = params.get("pair", "EURUSD=X")
398
+ period = params.get("period", "3mo")
399
+ if not _HAS_YF:
400
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
401
+ # Normalise to yfinance format
402
+ p = pair.replace("/", "").upper()
403
+ if not p.endswith("=X"):
404
+ p += "=X"
405
+ return _get_market_data({"symbol": p, "period": period})
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # 4. calculate_factors
410
+ # ---------------------------------------------------------------------------
411
+
412
+ def _calculate_factors(params: dict) -> dict:
413
+ symbol = params.get("symbol", "AAPL")
414
+ period = params.get("period", "1y")
415
+
416
+ # ── Try Alibaba Cloud enhanced factor engine first ─────────────────
417
+ if _HAS_CLOUD:
418
+ try:
419
+ cloud_factors = cloud_get_factors_sync(symbol)
420
+ if cloud_factors and cloud_factors.get("success"):
421
+ cloud_factors["provider"] = "aliyun_cloud"
422
+ return cloud_factors
423
+ except Exception as exc:
424
+ logger.debug("Cloud factors failed for %s: %s — computing locally", symbol, exc)
425
+
426
+ if _is_ashare(symbol) and _HAS_AK:
427
+ data_result = _get_ashare_data(
428
+ symbol,
429
+ _parse_date(None, 365),
430
+ _today(),
431
+ )
432
+ elif _HAS_YF:
433
+ data_result = _get_market_data({"symbol": symbol, "period": period})
434
+ else:
435
+ return {"success": False, "error": "No data source available"}
436
+
437
+ if not data_result.get("success"):
438
+ return data_result
439
+
440
+ # Re-fetch full history for factor computation
441
+ if _is_ashare(symbol) and _HAS_AK:
442
+ norm = _normalise_ashare(symbol)
443
+ code = norm[2:]
444
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
445
+ start_date=_parse_date(None, 365).replace("-", ""),
446
+ end_date=_today().replace("-", ""),
447
+ adjust="qfq")
448
+ df = df.rename(columns={"收盘": "Close", "成交量": "Volume",
449
+ "开盘": "Open", "最高": "High", "最低": "Low"})
450
+ else:
451
+ tkr = yf.Ticker(symbol)
452
+ df = tkr.history(period=period)
453
+
454
+ if df is None or len(df) < 20:
455
+ return {"success": False, "error": "Insufficient history for factors"}
456
+
457
+ close = df["Close"].astype(float)
458
+ volume = df["Volume"].astype(float)
459
+ ret = close.pct_change().dropna()
460
+
461
+ factors: Dict[str, Any] = {"symbol": symbol}
462
+
463
+ # ── Price momentum ─────────────────────────────────────────────────────
464
+ for n in (5, 10, 20, 60, 120):
465
+ if len(close) > n:
466
+ factors[f"return_{n}d"] = round(float(close.pct_change(n).iloc[-1]), 5)
467
+
468
+ # ── Moving averages ────────────────────────────────────────────────────
469
+ for n in (5, 10, 20, 60, 120, 200):
470
+ if len(close) >= n:
471
+ ma = close.rolling(n).mean().iloc[-1]
472
+ factors[f"ma_{n}"] = round(float(ma), 4)
473
+ factors[f"ma_{n}_gap"] = round(float(close.iloc[-1] / ma - 1), 5)
474
+
475
+ # ── Volatility ─────────────────────────────────────────────────────────
476
+ for n in (10, 20, 60):
477
+ if len(ret) >= n:
478
+ factors[f"volatility_{n}d"] = round(float(ret.tail(n).std() * np.sqrt(252)), 5)
479
+
480
+ # ── Volume ─────────────────────────────────────────────────────────────
481
+ if len(volume) >= 20:
482
+ vol_ma20 = volume.rolling(20).mean().iloc[-1]
483
+ factors["volume_ratio_20d"] = round(float(volume.iloc[-1] / vol_ma20), 3) if vol_ma20 > 0 else None
484
+
485
+ # ── RSI ────────────────────────────────────────────────────────────────
486
+ if _HAS_TA and len(close) >= 14:
487
+ ta = _get_pandas_ta()
488
+ if ta is not None:
489
+ rsi = ta.rsi(close, length=14)
490
+ if rsi is not None and not rsi.empty:
491
+ factors["rsi_14"] = round(float(rsi.iloc[-1]), 2)
492
+ else:
493
+ factors["rsi_14"] = None
494
+ else:
495
+ factors["rsi_14"] = None
496
+ else:
497
+ # Manual RSI
498
+ delta = close.diff()
499
+ gain = delta.clip(lower=0).rolling(14).mean()
500
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
501
+ rs = gain / loss.replace(0, np.nan)
502
+ rsi_v = (100 - 100 / (1 + rs)).iloc[-1]
503
+ factors["rsi_14"] = round(float(rsi_v), 2) if not np.isnan(rsi_v) else None
504
+
505
+ # ── MACD ───────────────────────────────────────────────────────────────
506
+ if len(close) >= 26:
507
+ ema12 = close.ewm(span=12, adjust=False).mean()
508
+ ema26 = close.ewm(span=26, adjust=False).mean()
509
+ macd = ema12 - ema26
510
+ sig = macd.ewm(span=9, adjust=False).mean()
511
+ factors["macd"] = round(float(macd.iloc[-1]), 5)
512
+ factors["macd_signal"] = round(float(sig.iloc[-1]), 5)
513
+ factors["macd_hist"] = round(float((macd - sig).iloc[-1]), 5)
514
+
515
+ # ── Bollinger Bands ────────────────────────────────────────────────────
516
+ if len(close) >= 20:
517
+ bb_ma = close.rolling(20).mean()
518
+ bb_std = close.rolling(20).std()
519
+ bb_up = bb_ma + 2 * bb_std
520
+ bb_lo = bb_ma - 2 * bb_std
521
+ prc = close.iloc[-1]
522
+ factors["bb_position"] = round(float((prc - bb_lo.iloc[-1]) /
523
+ (bb_up.iloc[-1] - bb_lo.iloc[-1] + 1e-8)), 4)
524
+ factors["bb_upper"] = round(float(bb_up.iloc[-1]), 4)
525
+ factors["bb_lower"] = round(float(bb_lo.iloc[-1]), 4)
526
+
527
+ # ── Beta vs market ─────────────────────────────────────────────────────
528
+ if _HAS_YF and not _is_ashare(symbol) and len(ret) >= 60:
529
+ try:
530
+ bench = yf.Ticker("SPY").history(period=period)["Close"].pct_change().dropna()
531
+ aligned = pd.DataFrame({"asset": ret, "bench": bench}).dropna().tail(60)
532
+ if len(aligned) >= 30:
533
+ beta_v = float(np.cov(aligned["asset"], aligned["bench"])[0, 1] /
534
+ np.var(aligned["bench"]))
535
+ factors["beta_60d"] = round(beta_v, 3)
536
+ except Exception:
537
+ pass
538
+
539
+ # ── Trend score ────────────────────────────────────────────────────────
540
+ trend = 0
541
+ for ma_key, w in [("ma_5_gap", 0.15), ("ma_20_gap", 0.35), ("ma_60_gap", 0.50)]:
542
+ v = factors.get(ma_key, 0.0) or 0.0
543
+ trend += w * (1 if v > 0 else -1)
544
+ factors["trend_score"] = round(trend, 3)
545
+
546
+ factors["provider"] = "local"
547
+ return {"success": True, **factors}
548
+
549
+
550
+ # ---------------------------------------------------------------------------
551
+ # 5. backtest_strategy
552
+ # ---------------------------------------------------------------------------
553
+
554
+ def _backtest_strategy(params: dict) -> dict:
555
+ """
556
+ Simple backtest engine. Supports:
557
+ strategy = "sma_cross" | "rsi_mean_revert" | "momentum" | "buy_hold"
558
+ """
559
+ symbol = params.get("symbol", "AAPL")
560
+ strategy = params.get("strategy", "sma_cross").lower().replace(" ", "_").replace("-", "_")
561
+ start = params.get("start", _parse_date(None, 3 * 365))
562
+ end = params.get("end", _today())
563
+ fast = int(params.get("fast_period", 20))
564
+ slow = int(params.get("slow_period", 60))
565
+ rsi_lo = float(params.get("rsi_oversold", 30))
566
+ rsi_hi = float(params.get("rsi_overbought", 70))
567
+ mom_n = int(params.get("momentum_period", 20))
568
+
569
+ # Fetch data
570
+ if _is_ashare(symbol) and _HAS_AK:
571
+ norm = _normalise_ashare(symbol)
572
+ code = norm[2:]
573
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
574
+ start_date=start.replace("-", ""),
575
+ end_date=end.replace("-", ""),
576
+ adjust="qfq")
577
+ df = df.rename(columns={"收盘": "Close", "成交量": "Volume",
578
+ "日期": "Date", "开盘": "Open",
579
+ "最高": "High", "最低": "Low"})
580
+ df["Date"] = pd.to_datetime(df["Date"])
581
+ df = df.set_index("Date").sort_index()
582
+ elif _HAS_YF:
583
+ df = yf.Ticker(symbol).history(start=start, end=end)
584
+ else:
585
+ return {"success": False, "error": "No data source available"}
586
+
587
+ if df is None or len(df) < max(slow, 60):
588
+ return {"success": False, "error": f"Insufficient data: {len(df) if df is not None else 0} bars"}
589
+
590
+ close = df["Close"].astype(float)
591
+
592
+ # ── Signal generation ─────────────────────────────────────────────────
593
+ if strategy in ("sma_cross", "ma_cross"):
594
+ ma_f = close.rolling(fast).mean()
595
+ ma_s = close.rolling(slow).mean()
596
+ signal = (ma_f > ma_s).astype(int)
597
+
598
+ elif strategy in ("rsi_mean_revert", "rsi"):
599
+ delta = close.diff()
600
+ gain = delta.clip(lower=0).rolling(14).mean()
601
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
602
+ rsi = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
603
+ signal = pd.Series(0, index=rsi.index)
604
+ in_pos = False
605
+ for i in range(len(rsi)):
606
+ r = rsi.iloc[i]
607
+ if np.isnan(r):
608
+ signal.iloc[i] = 0
609
+ elif r < rsi_lo and not in_pos:
610
+ in_pos = True
611
+ signal.iloc[i] = 1
612
+ elif r > rsi_hi and in_pos:
613
+ in_pos = False
614
+ signal.iloc[i] = 0
615
+ else:
616
+ signal.iloc[i] = int(in_pos)
617
+
618
+ elif strategy in ("momentum", "mom"):
619
+ ret_n = close.pct_change(mom_n)
620
+ signal = (ret_n > 0).astype(int)
621
+
622
+ else: # buy_hold
623
+ signal = pd.Series(1, index=close.index)
624
+
625
+ # ── Vectorbt backtest (if available) ─────────────────────────────────
626
+ if _HAS_VBT:
627
+ try:
628
+ pf = vbt.Portfolio.from_signals(
629
+ close, signal.shift(1).fillna(0) == 1,
630
+ signal.shift(1).fillna(0) == 0,
631
+ freq="D",
632
+ )
633
+ stats = pf.stats()
634
+ return {
635
+ "success": True,
636
+ "symbol": symbol,
637
+ "strategy": strategy,
638
+ "start": str(df.index[0].date()),
639
+ "end": str(df.index[-1].date()),
640
+ "bars": len(df),
641
+ "total_return": round(float(stats.get("Total Return [%]", 0) / 100), 4),
642
+ "annual_return": round(float(stats.get("Annualized Return [%]", 0) / 100), 4),
643
+ "sharpe_ratio": round(float(stats.get("Sharpe Ratio", 0) or 0), 3),
644
+ "sortino_ratio": round(float(stats.get("Sortino Ratio", 0) or 0), 3),
645
+ "max_drawdown": round(float(stats.get("Max Drawdown [%]", 0) / 100), 4),
646
+ "win_rate": round(float(stats.get("Win Rate [%]", 0) / 100), 3),
647
+ "total_trades": int(stats.get("Total Trades", 0) or 0),
648
+ "provider": "vectorbt",
649
+ }
650
+ except Exception as exc:
651
+ logger.debug("vectorbt failed, falling back to manual: %s", exc)
652
+
653
+ # ── Manual pandas backtest fallback ──────────────────────────────────
654
+ sig = signal.shift(1).fillna(0)
655
+ ret = close.pct_change().fillna(0)
656
+ port_ret = (ret * sig).fillna(0)
657
+ equity = (1 + port_ret).cumprod()
658
+
659
+ total_r = float(equity.iloc[-1] - 1)
660
+ n_years = max((df.index[-1] - df.index[0]).days / 365.25, 0.01)
661
+ annual_r = float((1 + total_r) ** (1 / n_years) - 1)
662
+ rf = 0.04 # risk-free rate
663
+ excess = port_ret - rf / 252
664
+ sharpe = float(excess.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0.0
665
+ neg_ret = port_ret[port_ret < 0]
666
+ sortino = float(excess.mean() / neg_ret.std() * np.sqrt(252)) if len(neg_ret) > 0 and neg_ret.std() > 0 else 0.0
667
+
668
+ # Max drawdown
669
+ peak = equity.cummax()
670
+ dd = (equity - peak) / peak
671
+ max_dd = float(dd.min())
672
+
673
+ # Trade stats
674
+ trades = (sig.diff() != 0) & (sig == 1)
675
+ exits = (sig.diff() != 0) & (sig == 0)
676
+ n_trades = int(trades.sum())
677
+ trade_rets = []
678
+ entry_date = None
679
+ entry_price = None
680
+ for date, row in sig.items():
681
+ if row == 1 and entry_price is None:
682
+ entry_price = float(close.loc[date])
683
+ entry_date = date
684
+ elif row == 0 and entry_price is not None:
685
+ trade_rets.append(float(close.loc[date]) / entry_price - 1)
686
+ entry_price = None
687
+
688
+ win_rate = sum(1 for r in trade_rets if r > 0) / len(trade_rets) if trade_rets else 0.0
689
+
690
+ # Benchmark: buy-and-hold
691
+ bh_ret = float(close.iloc[-1] / close.iloc[0] - 1)
692
+
693
+ return {
694
+ "success": True,
695
+ "symbol": symbol,
696
+ "strategy": strategy,
697
+ "start": str(df.index[0].date()),
698
+ "end": str(df.index[-1].date()),
699
+ "bars": len(df),
700
+ "total_return": round(total_r, 4),
701
+ "annual_return": round(annual_r, 4),
702
+ "sharpe_ratio": round(sharpe, 3),
703
+ "sortino_ratio": round(sortino, 3),
704
+ "max_drawdown": round(max_dd, 4),
705
+ "win_rate": round(win_rate, 3),
706
+ "total_trades": n_trades,
707
+ "benchmark_return": round(bh_ret, 4),
708
+ "alpha": round(annual_r - bh_ret / n_years, 4),
709
+ "provider": "pandas",
710
+ }
711
+
712
+
713
+ # ---------------------------------------------------------------------------
714
+ # 6. get_risk_metrics
715
+ # ---------------------------------------------------------------------------
716
+
717
+ def _get_risk_metrics(params: dict) -> dict:
718
+ symbol = params.get("symbol", "AAPL")
719
+ period = params.get("period", "1y")
720
+ conf_level = float(params.get("confidence", 0.95))
721
+
722
+ if _is_ashare(symbol) and _HAS_AK:
723
+ data = _get_ashare_data(symbol, _parse_date(None, 365), _today())
724
+ if not data.get("success"):
725
+ return data
726
+ norm = _normalise_ashare(symbol)
727
+ code = norm[2:]
728
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
729
+ start_date=_parse_date(None, 365).replace("-", ""),
730
+ end_date=_today().replace("-", ""),
731
+ adjust="qfq")
732
+ df = df.rename(columns={"收盘": "Close"})
733
+ close = df["Close"].astype(float)
734
+ elif _HAS_YF:
735
+ close = yf.Ticker(symbol).history(period=period)["Close"].astype(float)
736
+ else:
737
+ return {"success": False, "error": "No data source"}
738
+
739
+ if close is None or len(close) < 30:
740
+ return {"success": False, "error": "Insufficient data"}
741
+
742
+ ret = close.pct_change().dropna()
743
+ mu = float(ret.mean())
744
+ sig = float(ret.std())
745
+
746
+ # VaR (parametric)
747
+ if _HAS_SCIPY:
748
+ var_daily = float(-sp_stats.norm.ppf(1 - conf_level, mu, sig))
749
+ else:
750
+ var_daily = float(-np.percentile(ret, (1 - conf_level) * 100))
751
+
752
+ var_monthly = var_daily * np.sqrt(21)
753
+
754
+ # CVaR (Expected Shortfall)
755
+ losses = -ret
756
+ cvar = float(losses[losses >= var_daily].mean())
757
+
758
+ # Max drawdown
759
+ equity = (1 + ret).cumprod()
760
+ peak = equity.cummax()
761
+ dd = (equity - peak) / peak
762
+ max_dd = float(dd.min())
763
+
764
+ # Calmar
765
+ annual_ret = mu * 252
766
+ calmar = annual_ret / abs(max_dd) if max_dd != 0 else 0.0
767
+
768
+ # Downside deviation (Sortino denominator)
769
+ neg_ret = ret[ret < 0]
770
+ down_dev = float(neg_ret.std() * np.sqrt(252)) if len(neg_ret) > 0 else 0.0
771
+
772
+ # Skewness / kurtosis
773
+ skew_v = float(ret.skew()) if _HAS_SCIPY else 0.0
774
+ kurt_v = float(ret.kurtosis()) if _HAS_SCIPY else 0.0
775
+
776
+ return {
777
+ "success": True,
778
+ "symbol": symbol,
779
+ "confidence_level": conf_level,
780
+ "var_daily": round(var_daily, 5),
781
+ "var_monthly": round(float(var_monthly), 5),
782
+ "cvar_daily": round(cvar, 5),
783
+ "max_drawdown": round(max_dd, 5),
784
+ "annual_volatility": round(float(sig * np.sqrt(252)), 5),
785
+ "annual_return": round(float(annual_ret), 5),
786
+ "sharpe_ratio": round(float((annual_ret - 0.04) / (sig * np.sqrt(252))), 3) if sig > 0 else 0.0,
787
+ "calmar_ratio": round(float(calmar), 3),
788
+ "downside_deviation": round(down_dev, 5),
789
+ "skewness": round(skew_v, 3),
790
+ "kurtosis": round(kurt_v, 3),
791
+ "provider": "local",
792
+ }
793
+
794
+
795
+ # ---------------------------------------------------------------------------
796
+ # 7. optimize_positions
797
+ # ---------------------------------------------------------------------------
798
+
799
+ def _optimize_positions(params: dict) -> dict:
800
+ symbols = params.get("symbols", ["AAPL", "MSFT", "GOOGL"])
801
+ period = params.get("period", "1y")
802
+ method = params.get("method", "max_sharpe") # max_sharpe | min_var | equal_weight
803
+ rf = float(params.get("risk_free_rate", 0.04))
804
+
805
+ if isinstance(symbols, str):
806
+ symbols = [s.strip() for s in symbols.split(",")]
807
+
808
+ # Fetch returns
809
+ prices = {}
810
+ for sym in symbols:
811
+ if _is_ashare(sym) and _HAS_AK:
812
+ norm = _normalise_ashare(sym)
813
+ code = norm[2:]
814
+ df = ak.stock_zh_a_hist(symbol=code, period="daily",
815
+ start_date=_parse_date(None, 365).replace("-", ""),
816
+ end_date=_today().replace("-", ""),
817
+ adjust="qfq")
818
+ df = df.rename(columns={"收盘": "Close"})
819
+ prices[sym] = df["Close"].astype(float).values
820
+ elif _HAS_YF:
821
+ prices[sym] = yf.Ticker(sym).history(period=period)["Close"].astype(float).values
822
+
823
+ if not prices:
824
+ return {"success": False, "error": "Could not fetch prices"}
825
+
826
+ # Align length
827
+ min_len = min(len(v) for v in prices.values())
828
+ ret_mat = np.column_stack([prices[s][-min_len:] for s in symbols])
829
+ ret_mat = np.diff(np.log(ret_mat), axis=0)
830
+
831
+ mu_vec = ret_mat.mean(axis=0) * 252
832
+ cov_mat = np.cov(ret_mat.T) * 252
833
+ n = len(symbols)
834
+
835
+ if method == "equal_weight":
836
+ weights = np.ones(n) / n
837
+ elif method == "min_var":
838
+ # Analytical min-variance
839
+ try:
840
+ inv_cov = np.linalg.inv(cov_mat + 1e-8 * np.eye(n))
841
+ ones = np.ones(n)
842
+ w = inv_cov @ ones
843
+ weights = w / w.sum()
844
+ except np.linalg.LinAlgError:
845
+ weights = np.ones(n) / n
846
+ else: # max_sharpe — gradient-free grid search
847
+ best_sharpe = -np.inf
848
+ weights = np.ones(n) / n
849
+ rng = np.random.default_rng(42)
850
+ for _ in range(10000):
851
+ w_try = rng.dirichlet(np.ones(n))
852
+ p_ret = float(w_try @ mu_vec)
853
+ p_vol = float(np.sqrt(w_try @ cov_mat @ w_try))
854
+ sharpe = (p_ret - rf) / p_vol if p_vol > 0 else -np.inf
855
+ if sharpe > best_sharpe:
856
+ best_sharpe = sharpe
857
+ weights = w_try
858
+
859
+ # Portfolio metrics
860
+ p_ret = float(weights @ mu_vec)
861
+ p_vol = float(np.sqrt(weights @ cov_mat @ weights))
862
+ sharpe = (p_ret - rf) / p_vol if p_vol > 0 else 0.0
863
+
864
+ return {
865
+ "success": True,
866
+ "method": method,
867
+ "symbols": symbols,
868
+ "weights": {sym: round(float(w), 4) for sym, w in zip(symbols, weights)},
869
+ "portfolio_return": round(p_ret, 4),
870
+ "portfolio_vol": round(p_vol, 4),
871
+ "sharpe_ratio": round(sharpe, 3),
872
+ "provider": "local",
873
+ }
874
+
875
+
876
+ # ---------------------------------------------------------------------------
877
+ # 8. get_sector_performance (A股 + US)
878
+ # ---------------------------------------------------------------------------
879
+
880
+ def _get_sector_performance(params: dict) -> dict:
881
+ market = params.get("market", "cn").lower() # cn | us
882
+
883
+ if market == "cn" and _HAS_AK:
884
+ try:
885
+ df = _ak_retry(ak.stock_board_industry_name_em)
886
+ if df is None or df.empty:
887
+ raise ValueError("empty sector data")
888
+ df = df.rename(columns={
889
+ "板块名称": "sector", "最新价": "price",
890
+ "涨跌幅": "change_pct", "成交额": "amount",
891
+ "上涨家数": "rising", "下跌家数": "falling",
892
+ })
893
+ top = df.nlargest(5, "change_pct")[["sector", "change_pct"]].to_dict("records")
894
+ bottom = df.nsmallest(5, "change_pct")[["sector", "change_pct"]].to_dict("records")
895
+ return {
896
+ "success": True,
897
+ "market": "cn",
898
+ "date": _today(),
899
+ "top_sectors": top,
900
+ "bottom_sectors": bottom,
901
+ "total_sectors": len(df),
902
+ "provider": "akshare",
903
+ }
904
+ except Exception as exc:
905
+ return {"success": False, "error": str(exc)}
906
+
907
+ elif _HAS_YF:
908
+ sector_etfs = {
909
+ "Technology": "XLK", "Healthcare": "XLV",
910
+ "Financials": "XLF", "Consumer Disc": "XLY",
911
+ "Industrials": "XLI", "Energy": "XLE",
912
+ "Utilities": "XLU", "Real Estate": "XLRE",
913
+ "Materials": "XLB", "Comm Services": "XLC",
914
+ "Consumer Staples": "XLP",
915
+ }
916
+ perf = []
917
+ for name, etf in sector_etfs.items():
918
+ try:
919
+ h = yf.Ticker(etf).history(period="5d")
920
+ if len(h) >= 2:
921
+ chg = (h["Close"].iloc[-1] - h["Close"].iloc[-2]) / h["Close"].iloc[-2] * 100
922
+ perf.append({"sector": name, "etf": etf, "change_pct": round(float(chg), 2)})
923
+ except Exception:
924
+ pass
925
+ perf.sort(key=lambda x: x["change_pct"], reverse=True)
926
+ return {
927
+ "success": True,
928
+ "market": "us",
929
+ "date": _today(),
930
+ "sectors": perf,
931
+ "top_sectors": perf[:3],
932
+ "bottom_sectors": perf[-3:],
933
+ "provider": "yfinance",
934
+ }
935
+
936
+ return {"success": False, "error": "akshare / yfinance not available: 运行 pip install akshare yfinance 或 /install akshare yfinance"}
937
+
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # 9. A股 northbound fund flow (北向资金)
941
+ # ---------------------------------------------------------------------------
942
+
943
+ def _get_northbound_flow(params: dict) -> dict:
944
+ if not _HAS_AK:
945
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
946
+ try:
947
+ # stock_hsgt_fund_flow_summary_em returns today's 沪深港通 summary.
948
+ # 成交净买额 is already in 亿元 — no further scaling needed.
949
+ df = _ak_retry(ak.stock_hsgt_fund_flow_summary_em)
950
+ north = df[df["资金方向"] == "北向"]
951
+ if north.empty:
952
+ return {"success": False, "error": "No northbound data in response"}
953
+ sh_flow = float(north[north["板块"] == "沪股通"]["成交净买额"].sum())
954
+ sz_flow = float(north[north["板块"] == "深股通"]["成交净买额"].sum())
955
+ total = round(sh_flow + sz_flow, 2)
956
+ return {
957
+ "success": True,
958
+ "latest_net_buy_yi": total,
959
+ "sh_net_buy_yi": round(sh_flow, 2),
960
+ "sz_net_buy_yi": round(sz_flow, 2),
961
+ "total_net_buy_yi": total,
962
+ "trend": "inflow" if total > 0 else "outflow",
963
+ "provider": "akshare",
964
+ }
965
+ except Exception as exc:
966
+ return {"success": False, "error": str(exc)}
967
+
968
+
969
+ # ---------------------------------------------------------------------------
970
+ # 10. screen_ashare — 选股筛选器
971
+ # ---------------------------------------------------------------------------
972
+
973
+ def _screen_ashare(params: dict) -> dict:
974
+ """Screen A-share stocks by fundamental & technical criteria."""
975
+ min_roe = float(params.get("min_roe", 10))
976
+ max_pe = float(params.get("max_pe", 50))
977
+ min_revenue_gr = float(params.get("min_revenue_growth", 10))
978
+ min_market_cap = float(params.get("min_market_cap_yi", 0)) # 亿元
979
+ limit = int(params.get("limit", 20))
980
+
981
+ # Primary: direct eastmoney clist (host-rotating, proxy-resilient, small
982
+ # paged query). Far more reliable than akshare's full-market spot endpoint.
983
+ try:
984
+ from aria_code.market_data_client import screen_ashare as _em_screen
985
+ _em = _em_screen(max_pe=max_pe, min_market_cap_yi=min_market_cap, limit=limit)
986
+ if _em.get("success") and _em.get("stocks"):
987
+ _em["criteria"] = params
988
+ return _em
989
+ except Exception:
990
+ pass # fall through to akshare
991
+
992
+ if not _HAS_AK:
993
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
994
+
995
+ try:
996
+ # A股实时行情
997
+ df = _ak_retry(ak.stock_zh_a_spot_em)
998
+ if df is None or df.empty:
999
+ return {"success": False, "error": "No spot data"}
1000
+
1001
+ df = df.rename(columns={
1002
+ "代码": "code", "名称": "name", "最新价": "price",
1003
+ "涨跌幅": "change_pct", "总市值": "market_cap",
1004
+ "市盈率-动态": "pe_dynamic", "市净率": "pb",
1005
+ "成交量": "volume", "换手率": "turnover_rate",
1006
+ })
1007
+
1008
+ # Basic filters
1009
+ df = df[~df["name"].str.contains("ST|退", na=False)]
1010
+ df = df[df["price"].notna() & (df["price"] > 0)]
1011
+
1012
+ if "pe_dynamic" in df.columns:
1013
+ df = df[df["pe_dynamic"].between(0.1, max_pe, inclusive="both")]
1014
+
1015
+ if "market_cap" in df.columns and min_market_cap > 0:
1016
+ df = df[df["market_cap"] >= min_market_cap * 1e8]
1017
+
1018
+ # Score on momentum
1019
+ if "change_pct" in df.columns:
1020
+ df["score"] = df["change_pct"].fillna(0)
1021
+ df = df.nlargest(limit, "score")
1022
+
1023
+ cols = ["code", "name", "price", "change_pct", "pe_dynamic", "pb",
1024
+ "market_cap", "turnover_rate"]
1025
+ cols = [c for c in cols if c in df.columns]
1026
+ result_df = df[cols].head(limit)
1027
+
1028
+ # Format market_cap to 亿元
1029
+ if "market_cap" in result_df.columns:
1030
+ result_df = result_df.copy()
1031
+ result_df["market_cap_yi"] = (result_df["market_cap"] / 1e8).round(1)
1032
+
1033
+ stocks = result_df.to_dict("records")
1034
+ return {
1035
+ "success": True,
1036
+ "count": len(stocks),
1037
+ "stocks": stocks,
1038
+ "criteria": params,
1039
+ "provider": "akshare",
1040
+ }
1041
+ except Exception as exc:
1042
+ return {"success": False, "error": str(exc)}
1043
+
1044
+
1045
+ # ---------------------------------------------------------------------------
1046
+ # 11. get_limit_up_pool — 涨停板池
1047
+ # ---------------------------------------------------------------------------
1048
+
1049
+ def _get_limit_up_pool(params: dict) -> dict:
1050
+ date = params.get("date", _today())
1051
+ if not _HAS_AK:
1052
+ return {"success": False, "error": "akshare not installed: 运行 pip install akshare 或 /install akshare"}
1053
+ try:
1054
+ date_str = date.replace("-", "")
1055
+ df = _ak_retry(ak.stock_zt_pool_em, date=date_str)
1056
+ if df is None or df.empty:
1057
+ return {"success": True, "count": 0, "stocks": [], "date": date}
1058
+
1059
+ df = df.rename(columns={
1060
+ "代码": "code", "名称": "name",
1061
+ "涨停统计": "limit_streak", "连续涨停": "consecutive",
1062
+ "首次封板时间": "first_lock_time", "涨停类型": "limit_type",
1063
+ })
1064
+ cols = [c for c in ["code", "name", "limit_streak", "consecutive",
1065
+ "first_lock_time", "limit_type"] if c in df.columns]
1066
+ return {
1067
+ "success": True,
1068
+ "date": date,
1069
+ "count": len(df),
1070
+ "stocks": df[cols].head(50).to_dict("records"),
1071
+ "provider": "akshare",
1072
+ }
1073
+ except Exception as exc:
1074
+ return {"success": False, "error": str(exc)}
1075
+
1076
+
1077
+ # ---------------------------------------------------------------------------
1078
+ # 12. get_market_indices
1079
+ # ---------------------------------------------------------------------------
1080
+
1081
+ def _get_market_indices(params: dict) -> dict:
1082
+ indices = {
1083
+ # US
1084
+ "S&P 500": "^GSPC", "NASDAQ": "^IXIC", "Dow Jones": "^DJI",
1085
+ "VIX": "^VIX", "Russell 2000": "^RUT",
1086
+ # CN
1087
+ "上证综指": "000001.SS", "深证成指": "399001.SZ", "创业板": "399006.SZ",
1088
+ # Global
1089
+ "Nikkei 225": "^N225", "FTSE 100": "^FTSE", "DAX": "^GDAXI",
1090
+ "Hang Seng": "^HSI",
1091
+ # Commodities
1092
+ "Gold": "GC=F", "Crude Oil": "CL=F", "Bitcoin": "BTC-USD",
1093
+ }
1094
+
1095
+ if not _HAS_YF:
1096
+ if _HAS_AK:
1097
+ # A股 indices only
1098
+ try:
1099
+ df = ak.stock_zh_index_spot_em()
1100
+ return {"success": True, "indices": df.head(10).to_dict("records"), "provider": "akshare"}
1101
+ except Exception as exc:
1102
+ return {"success": False, "error": str(exc)}
1103
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
1104
+
1105
+ results = []
1106
+ for name, ticker in indices.items():
1107
+ try:
1108
+ h = yf.Ticker(ticker).history(period="2d")
1109
+ if len(h) >= 1:
1110
+ latest = float(h["Close"].iloc[-1])
1111
+ chg = (float(h["Close"].iloc[-1]) - float(h["Close"].iloc[0])) / float(h["Close"].iloc[0]) * 100 if len(h) >= 2 else 0.0
1112
+ results.append({"name": name, "ticker": ticker,
1113
+ "price": round(latest, 2), "change_pct": round(chg, 2)})
1114
+ except Exception:
1115
+ pass
1116
+
1117
+ return {"success": True, "indices": results, "date": _today(), "provider": "yfinance"}
1118
+
1119
+
1120
+ # ---------------------------------------------------------------------------
1121
+ # 13. analyze_news (local sentiment via keyword scoring)
1122
+ # ---------------------------------------------------------------------------
1123
+
1124
+ def _get_data_key(service: str) -> str:
1125
+ """Read a data service API key from env var or ~/.arthera/providers.json."""
1126
+ _DATA_ENV = {
1127
+ "finnhub": "FINNHUB_API_KEY",
1128
+ "newsapi": "NEWS_API_KEY",
1129
+ "brave": "BRAVE_SEARCH_API_KEY",
1130
+ "alphavantage": "ALPHA_VANTAGE_API_KEY",
1131
+ "coingecko": "COINGECKO_API_KEY",
1132
+ "twelvedata": "TWELVEDATA_API_KEY",
1133
+ }
1134
+ env_var = _DATA_ENV.get(service, "")
1135
+ if env_var:
1136
+ val = os.getenv(env_var, "")
1137
+ if val:
1138
+ return val
1139
+ # Fall back to providers.json
1140
+ try:
1141
+ import pathlib as _pl
1142
+ import json as _json
1143
+ pf = aria_home() / "providers.json"
1144
+ if pf.exists():
1145
+ raw = _json.loads(pf.read_text(encoding="utf-8"))
1146
+ entry = raw.get("data", {}).get(service, {})
1147
+ if entry.get("api_key"):
1148
+ return entry["api_key"]
1149
+ except Exception:
1150
+ pass
1151
+ return ""
1152
+
1153
+
1154
+ def _fetch_news_finnhub(symbol: str, limit: int) -> list:
1155
+ """Fetch stock news from Finnhub API."""
1156
+ key = _get_data_key("finnhub")
1157
+ if not key:
1158
+ return []
1159
+ try:
1160
+ import requests as _req
1161
+ import datetime as _dt
1162
+ end_dt = _dt.date.today().isoformat()
1163
+ start_dt = (_dt.date.today() - _dt.timedelta(days=7)).isoformat()
1164
+ url = (f"https://finnhub.io/api/v1/company-news"
1165
+ f"?symbol={symbol}&from={start_dt}&to={end_dt}&token={key}")
1166
+ resp = _req.get(url, timeout=8)
1167
+ if resp.status_code == 200:
1168
+ items = resp.json()[:limit]
1169
+ return [{"title": a.get("headline", ""), "source": a.get("source", ""),
1170
+ "time": str(a.get("datetime", "")), "url": a.get("url", "")}
1171
+ for a in items]
1172
+ except Exception:
1173
+ pass
1174
+ return []
1175
+
1176
+
1177
+ def _fetch_news_newsapi(query: str, limit: int) -> list:
1178
+ """Fetch news from NewsAPI.org."""
1179
+ key = _get_data_key("newsapi")
1180
+ if not key:
1181
+ return []
1182
+ try:
1183
+ import requests as _req
1184
+ url = (f"https://newsapi.org/v2/everything"
1185
+ f"?q={query}&language=en&sortBy=publishedAt&pageSize={limit}&apiKey={key}")
1186
+ resp = _req.get(url, timeout=8)
1187
+ if resp.status_code == 200:
1188
+ data = resp.json()
1189
+ return [{"title": a.get("title", ""), "source": a.get("source", {}).get("name", ""),
1190
+ "time": a.get("publishedAt", "")[:10], "url": a.get("url", "")}
1191
+ for a in data.get("articles", [])[:limit]]
1192
+ except Exception:
1193
+ pass
1194
+ return []
1195
+
1196
+
1197
+ def _analyze_news(params: dict) -> dict:
1198
+ symbol = params.get("symbol", "")
1199
+ query = params.get("query", params.get("topic", symbol))
1200
+ topic = query or symbol
1201
+ limit = int(params.get("limit", 5))
1202
+
1203
+ # ── 1. A股 news via akshare (no key needed) ───────────────────────────────
1204
+ if _HAS_AK and topic and _is_ashare(topic):
1205
+ try:
1206
+ norm = _normalise_ashare(topic) if _is_ashare(topic) else topic
1207
+ code = norm[2:] if len(norm) > 2 else norm
1208
+ df = ak.stock_news_em(symbol=code)
1209
+ if df is not None and not df.empty:
1210
+ df = df.head(limit)
1211
+ news_list = []
1212
+ for _, row in df.iterrows():
1213
+ title = str(row.get("新闻标题", row.get("title", "")))
1214
+ score = _score_sentiment(title)
1215
+ news_list.append({
1216
+ "title": title,
1217
+ "time": str(row.get("发布时间", "")),
1218
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1219
+ "score": score,
1220
+ })
1221
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1222
+ return {
1223
+ "success": True, "symbol": topic, "news": news_list,
1224
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1225
+ "avg_score": round(avg_score, 3), "provider": "akshare",
1226
+ }
1227
+ except Exception as exc:
1228
+ pass # fall through to other providers
1229
+
1230
+ # ── 2. Finnhub (if key available) ────────────────────────────────────────
1231
+ if symbol and not _is_ashare(symbol):
1232
+ articles = _fetch_news_finnhub(symbol.upper(), limit)
1233
+ if articles:
1234
+ news_list = []
1235
+ for a in articles:
1236
+ score = _score_sentiment(a["title"])
1237
+ news_list.append({**a, "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"), "score": score})
1238
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1239
+ return {
1240
+ "success": True, "symbol": symbol, "news": news_list,
1241
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1242
+ "avg_score": round(avg_score, 3), "provider": "finnhub",
1243
+ }
1244
+
1245
+ # ── 3. NewsAPI (if key available) ─────────────────────────────────────────
1246
+ search_query = topic or symbol or "market"
1247
+ articles = _fetch_news_newsapi(search_query, limit)
1248
+ if articles:
1249
+ news_list = []
1250
+ for a in articles:
1251
+ score = _score_sentiment(a["title"])
1252
+ news_list.append({**a, "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"), "score": score})
1253
+ avg_score = sum(n["score"] for n in news_list) / len(news_list) if news_list else 0
1254
+ return {
1255
+ "success": True, "symbol": topic, "news": news_list,
1256
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1257
+ "avg_score": round(avg_score, 3), "provider": "newsapi",
1258
+ }
1259
+
1260
+ # ── 4. yfinance news (free, no key, US/HK/global stocks) ─────────────────
1261
+ yf_sym = symbol.upper() if symbol else ""
1262
+ if yf_sym and not _is_ashare(yf_sym):
1263
+ try:
1264
+ import yfinance as yf
1265
+ ticker = yf.Ticker(yf_sym)
1266
+ raw_news = ticker.news or []
1267
+ news_list = []
1268
+ for item in raw_news[:limit]:
1269
+ content = item.get("content", {})
1270
+ title = (
1271
+ content.get("title")
1272
+ or item.get("title", "")
1273
+ )
1274
+ pub = (
1275
+ content.get("pubDate")
1276
+ or item.get("providerPublishTime", "")
1277
+ )
1278
+ url = (
1279
+ content.get("canonicalUrl", {}).get("url")
1280
+ or item.get("link", "")
1281
+ )
1282
+ provider = (
1283
+ content.get("provider", {}).get("displayName")
1284
+ or item.get("publisher", "")
1285
+ )
1286
+ if not title:
1287
+ continue
1288
+ score = _score_sentiment(title)
1289
+ news_list.append({
1290
+ "title": title,
1291
+ "time": str(pub),
1292
+ "url": url,
1293
+ "publisher": provider,
1294
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1295
+ "score": score,
1296
+ })
1297
+ if news_list:
1298
+ avg_score = sum(n["score"] for n in news_list) / len(news_list)
1299
+ return {
1300
+ "success": True, "symbol": yf_sym, "news": news_list,
1301
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1302
+ "avg_score": round(avg_score, 3), "provider": "yfinance",
1303
+ }
1304
+ except Exception:
1305
+ pass
1306
+
1307
+ # ── 5. web_search fallback — search "[symbol] news" ──────────────────────
1308
+ ws_query = f"{topic or symbol} stock news latest" if (topic or symbol) else ""
1309
+ if ws_query:
1310
+ ws_result = _web_search({"query": ws_query, "max_results": limit})
1311
+ if ws_result.get("success") and ws_result.get("results"):
1312
+ news_list = []
1313
+ for item in ws_result["results"]:
1314
+ title = item.get("title", "")
1315
+ score = _score_sentiment(title)
1316
+ news_list.append({
1317
+ "title": title,
1318
+ "url": item.get("url", ""),
1319
+ "publisher": item.get("source", ""),
1320
+ "sentiment": "positive" if score > 0 else ("negative" if score < 0 else "neutral"),
1321
+ "score": score,
1322
+ })
1323
+ if news_list:
1324
+ avg_score = sum(n["score"] for n in news_list) / len(news_list)
1325
+ return {
1326
+ "success": True, "symbol": topic or symbol, "news": news_list,
1327
+ "overall_sentiment": "positive" if avg_score > 0.1 else ("negative" if avg_score < -0.1 else "neutral"),
1328
+ "avg_score": round(avg_score, 3), "provider": ws_result.get("provider", "web_search"),
1329
+ }
1330
+
1331
+ # ── 6. No data available ──────────────────────────────────────────────────
1332
+ tip = "配置数据服务 key: /apikey set finnhub <key> 或 /apikey set newsapi <key>;或设置 BRAVE_SEARCH_API_KEY 启用网页搜索"
1333
+ return {"success": False, "error": tip}
1334
+
1335
+
1336
+ def _score_sentiment(text: str) -> float:
1337
+ """Keyword-based sentiment scorer (0.0 = neutral, +1 = very positive, -1 = very negative)."""
1338
+ pos = ["上涨", "涨停", "突破", "创新高", "利好", "增长", "盈利", "bull", "beat", "growth", "record", "profit", "rally", "buy", "upgrade"]
1339
+ neg = ["下跌", "跌停", "亏损", "利空", "减少", "违规", "被罚", "风险", "bear", "miss", "loss", "decline", "sell", "downgrade", "fraud"]
1340
+ t = text.lower()
1341
+ score = sum(1 for w in pos if w in t) - sum(1 for w in neg if w in t)
1342
+ return float(max(-1, min(1, score * 0.25)))
1343
+
1344
+
1345
+ # ---------------------------------------------------------------------------
1346
+ # 14. get_commodities_data — gold, silver, oil, gas, copper, wheat, etc.
1347
+ # ---------------------------------------------------------------------------
1348
+
1349
+ # Common commodity keywords → yfinance futures tickers
1350
+ _COMMODITY_MAP: Dict[str, str] = {
1351
+ # Precious metals
1352
+ "gold": "GC=F", "silver": "SI=F", "platinum": "PL=F", "palladium": "PA=F",
1353
+ # Energy
1354
+ "oil": "CL=F", "crude": "CL=F", "crude oil": "CL=F",
1355
+ "brent": "BZ=F", "natural gas": "NG=F", "natgas": "NG=F", "gas": "NG=F",
1356
+ "gasoline": "RB=F", "heating oil": "HO=F",
1357
+ # Base metals
1358
+ "copper": "HG=F", "aluminum": "ALI=F", "nickel": "NI=F", "zinc": "ZNC=F",
1359
+ # Agricultural
1360
+ "wheat": "ZW=F", "corn": "ZC=F", "soybean": "ZS=F", "soybeans": "ZS=F",
1361
+ "coffee": "KC=F", "cocoa": "CC=F", "sugar": "SB=F", "cotton": "CT=F",
1362
+ "rice": "ZR=F", "oats": "ZO=F",
1363
+ # Livestock
1364
+ "cattle": "LE=F", "hogs": "HE=F",
1365
+ # Softs / other
1366
+ "lumber": "LBS=F", "rubber": "rubber", "iron ore": "TIO=F",
1367
+ # China-traded (A股 futures) — map to closest international proxy
1368
+ "螺纹钢": "HG=F", "铁矿石": "TIO=F", "铜": "HG=F",
1369
+ "黄金": "GC=F", "白银": "SI=F", "原油": "CL=F", "天然气": "NG=F",
1370
+ }
1371
+
1372
+
1373
+ def _get_commodities_data(params: dict) -> dict:
1374
+ """Commodity spot/futures price lookup via yfinance."""
1375
+ commodity = str(params.get("commodity", "gold")).strip().lower()
1376
+
1377
+ if not _HAS_YF:
1378
+ return {"success": False, "error": "yfinance not installed: pip install yfinance"}
1379
+
1380
+ # Resolve ticker
1381
+ ticker = _COMMODITY_MAP.get(commodity)
1382
+ if ticker is None:
1383
+ # Try direct uppercase (user may have passed ticker like GC=F)
1384
+ ticker = commodity.upper()
1385
+
1386
+ try:
1387
+ tkr = yf.Ticker(ticker)
1388
+ hist = tkr.history(period="5d")
1389
+ if hist.empty:
1390
+ # Fallback: try without =F suffix
1391
+ alt = commodity.upper().replace("=F", "") + "=F"
1392
+ hist = yf.Ticker(alt).history(period="5d")
1393
+ if hist.empty:
1394
+ return {
1395
+ "success": False,
1396
+ "error": f"No data for commodity '{commodity}' (ticker={ticker}). "
1397
+ "Try using the yfinance ticker directly, e.g. GC=F for gold.",
1398
+ }
1399
+ ticker = alt
1400
+
1401
+ latest = float(hist["Close"].iloc[-1])
1402
+ prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else latest
1403
+ chg = (latest - prev) / prev * 100 if prev else 0.0
1404
+ high_5d = float(hist["High"].max())
1405
+ low_5d = float(hist["Low"].min())
1406
+ vol_5d = float(hist["Volume"].mean())
1407
+
1408
+ # Longer term context
1409
+ hist_1y = tkr.history(period="1y")
1410
+ high_52w = float(hist_1y["High"].max()) if not hist_1y.empty else None
1411
+ low_52w = float(hist_1y["Low"].min()) if not hist_1y.empty else None
1412
+ ret_1y = float((hist_1y["Close"].iloc[-1] / hist_1y["Close"].iloc[0] - 1)) \
1413
+ if len(hist_1y) > 1 else None
1414
+
1415
+ info = tkr.fast_info
1416
+ currency = getattr(info, "currency", "USD")
1417
+
1418
+ return {
1419
+ "success": True,
1420
+ "commodity": commodity,
1421
+ "ticker": ticker,
1422
+ "latest_price": round(latest, 4),
1423
+ "change_pct": round(chg, 3),
1424
+ "currency": currency,
1425
+ "high_5d": round(high_5d, 4),
1426
+ "low_5d": round(low_5d, 4),
1427
+ "volume_5d_avg": int(vol_5d),
1428
+ "high_52w": round(high_52w, 4) if high_52w else None,
1429
+ "low_52w": round(low_52w, 4) if low_52w else None,
1430
+ "return_1y": round(ret_1y, 4) if ret_1y is not None else None,
1431
+ "pct_from_52w_high": round((latest / high_52w - 1), 4) if high_52w else None,
1432
+ "provider": "yfinance",
1433
+ }
1434
+ except Exception as exc:
1435
+ return {"success": False, "error": str(exc)}
1436
+
1437
+
1438
+ # ---------------------------------------------------------------------------
1439
+ # 15. get_futures_data — generic futures via yfinance
1440
+ # ---------------------------------------------------------------------------
1441
+
1442
+ def _get_futures_data(params: dict) -> dict:
1443
+ """Futures contract data (equity index futures, VIX, etc.)"""
1444
+ contract = str(params.get("contract", params.get("symbol", "ES=F"))).strip().upper()
1445
+
1446
+ # Common index futures shortcuts
1447
+ _FUTURES_MAP = {
1448
+ "SP500": "ES=F", "SPX": "ES=F", "S&P": "ES=F",
1449
+ "NQ": "NQ=F", "NASDAQ": "NQ=F",
1450
+ "DOW": "YM=F", "DJIA": "YM=F",
1451
+ "RUSSELL": "RTY=F", "RUT": "RTY=F",
1452
+ "VIX": "^VIX",
1453
+ "NIKKEI": "NK=F", "DAX": "FDAX=F",
1454
+ "HSI": "HSI=F",
1455
+ }
1456
+ ticker = _FUTURES_MAP.get(contract, contract)
1457
+ if not ticker.endswith("=F") and not ticker.startswith("^"):
1458
+ ticker = ticker + "=F"
1459
+
1460
+ return _get_market_data({"symbol": ticker, "period": "5d"})
1461
+
1462
+
1463
+ # ---------------------------------------------------------------------------
1464
+ # 15b. get_bonds_data (US Treasury yields via yfinance)
1465
+ # ---------------------------------------------------------------------------
1466
+
1467
+ def _get_bonds_data(params: dict) -> dict:
1468
+ if not _HAS_YF:
1469
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
1470
+ tickers = {
1471
+ "2Y": "^IRX", "5Y": "^FVX", "10Y": "^TNX", "30Y": "^TYX",
1472
+ }
1473
+ results = {}
1474
+ for tenor, sym in tickers.items():
1475
+ try:
1476
+ h = yf.Ticker(sym).history(period="5d")
1477
+ if not h.empty:
1478
+ results[tenor] = round(float(h["Close"].iloc[-1]), 3)
1479
+ except Exception:
1480
+ pass
1481
+ if not results:
1482
+ return {"success": False, "error": "Could not fetch yield data"}
1483
+ # Yield curve shape
1484
+ if "2Y" in results and "10Y" in results:
1485
+ results["10Y_2Y_spread"] = round(results["10Y"] - results["2Y"], 3)
1486
+ results["curve_shape"] = "normal" if results["10Y_2Y_spread"] > 0 else "inverted"
1487
+ return {"success": True, "yields": results, "provider": "yfinance"}
1488
+
1489
+
1490
+ # ---------------------------------------------------------------------------
1491
+ # 16. get_ai_signal — Alibaba Cloud DeepSeek-powered signal
1492
+ # ---------------------------------------------------------------------------
1493
+
1494
+ def _get_ai_signal(params: dict) -> dict:
1495
+ """
1496
+ AI trading signal from the Alibaba Cloud quant backend.
1497
+ Returns: action (BUY/SELL/HOLD), confidence, reasoning, stop_loss, take_profit.
1498
+ Falls back to calculate_factors when cloud is unavailable.
1499
+ """
1500
+ symbol = params.get("symbol", "600519")
1501
+ market = params.get("market", "CN" if _is_ashare(symbol) else "US")
1502
+
1503
+ if _HAS_CLOUD:
1504
+ try:
1505
+ result = cloud_get_ai_signal_sync(symbol, market=market)
1506
+ if result and result.get("action"):
1507
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1508
+ except Exception as exc:
1509
+ logger.debug("Cloud AI signal failed: %s", exc)
1510
+
1511
+ # Local fallback: derive a simple signal from factors
1512
+ factors = _calculate_factors({"symbol": symbol})
1513
+ if not factors.get("success"):
1514
+ return factors
1515
+
1516
+ rsi = factors.get("rsi_14", 50)
1517
+ macd_h = factors.get("macd_hist", 0) or 0
1518
+ trend = factors.get("trend_score", 0) or 0
1519
+ vol_r = factors.get("volume_ratio_20d", 1.0) or 1.0
1520
+
1521
+ score = 0
1522
+ if rsi is not None:
1523
+ score += (0.4 if rsi < 40 else -0.4 if rsi > 70 else 0)
1524
+ score += (0.3 if macd_h > 0 else -0.3 if macd_h < 0 else 0)
1525
+ score += trend * 0.3
1526
+
1527
+ action = "BUY" if score > 0.3 else "SELL" if score < -0.3 else "HOLD"
1528
+ confidence = min(abs(score), 1.0)
1529
+
1530
+ return {
1531
+ "success": True,
1532
+ "symbol": symbol,
1533
+ "action": action,
1534
+ "confidence": round(confidence, 3),
1535
+ "reasoning": f"RSI={rsi}, MACD_hist={macd_h:.5f}, trend={trend:.3f}, vol_ratio={vol_r:.2f}",
1536
+ "stop_loss": None,
1537
+ "take_profit": None,
1538
+ "provider": "local_fallback",
1539
+ }
1540
+
1541
+
1542
+ # ---------------------------------------------------------------------------
1543
+ # 17. get_market_insights — AI narrative analysis
1544
+ # ---------------------------------------------------------------------------
1545
+
1546
+ def _get_market_insights(params: dict) -> dict:
1547
+ """
1548
+ Narrative market insights from Alibaba Cloud AI service.
1549
+ symbols: list of stock codes or comma-separated string.
1550
+ """
1551
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1552
+ if isinstance(raw, str):
1553
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1554
+ else:
1555
+ symbols = list(raw)
1556
+ market = params.get("market", "CN")
1557
+
1558
+ if _HAS_CLOUD:
1559
+ try:
1560
+ from aria_code.aliyun_data_client import run_async
1561
+ result = run_async(AliyunDataClient.get().get_market_insights(symbols, market=market))
1562
+ if result:
1563
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1564
+ except Exception as exc:
1565
+ logger.debug("Cloud market insights failed: %s", exc)
1566
+
1567
+ # Local fallback: multi-stock factor summary
1568
+ summaries = []
1569
+ for sym in symbols[:5]:
1570
+ f = _calculate_factors({"symbol": sym})
1571
+ if f.get("success"):
1572
+ summaries.append({
1573
+ "symbol": sym,
1574
+ "rsi_14": f.get("rsi_14"),
1575
+ "trend_score": f.get("trend_score"),
1576
+ "macd_hist": f.get("macd_hist"),
1577
+ "vol_ratio": f.get("volume_ratio_20d"),
1578
+ })
1579
+ if not summaries:
1580
+ return {"success": False, "error": "Could not compute local factors"}
1581
+
1582
+ return {
1583
+ "success": True,
1584
+ "symbols": symbols,
1585
+ "summaries": summaries,
1586
+ "note": "Cloud AI not available — showing local factor summary",
1587
+ "provider": "local_fallback",
1588
+ }
1589
+
1590
+
1591
+ # ---------------------------------------------------------------------------
1592
+ # 18. get_predictions — ML model predictions from cloud
1593
+ # ---------------------------------------------------------------------------
1594
+
1595
+ def _get_predictions(params: dict) -> dict:
1596
+ """
1597
+ ML-powered stock return predictions from Alibaba Cloud QuantEngine.
1598
+ Falls back to momentum signal locally.
1599
+ """
1600
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1601
+ if isinstance(raw, str):
1602
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1603
+ else:
1604
+ symbols = list(raw)
1605
+ days = int(params.get("prediction_days", 5))
1606
+ market = params.get("market", "CN")
1607
+
1608
+ if _HAS_CLOUD:
1609
+ try:
1610
+ from aria_code.aliyun_data_client import run_async
1611
+ result = run_async(AliyunDataClient.get().get_predictions(symbols, prediction_days=days, market=market))
1612
+ if result and result.get("predictions"):
1613
+ return {"success": True, **result, "provider": "aliyun_cloud"}
1614
+ except Exception as exc:
1615
+ logger.debug("Cloud predictions failed: %s", exc)
1616
+
1617
+ # Local fallback: simple momentum prediction
1618
+ preds = []
1619
+ for sym in symbols[:5]:
1620
+ f = _calculate_factors({"symbol": sym})
1621
+ if f.get("success"):
1622
+ r5 = f.get("return_5d", 0) or 0
1623
+ r20 = f.get("return_20d", 0) or 0
1624
+ predicted_return = round((r5 * 0.4 + r20 * 0.6) * (days / 20), 4)
1625
+ preds.append({
1626
+ "symbol": sym,
1627
+ "predicted_return": predicted_return,
1628
+ "confidence": 0.5,
1629
+ "method": "momentum",
1630
+ })
1631
+
1632
+ if not preds:
1633
+ return {"success": False, "error": "No data for predictions"}
1634
+
1635
+ return {
1636
+ "success": True,
1637
+ "predictions": preds,
1638
+ "prediction_days": days,
1639
+ "provider": "local_fallback",
1640
+ }
1641
+
1642
+
1643
+ # ---------------------------------------------------------------------------
1644
+ # 19. cloud_backtest — advanced ML-powered backtest via Alibaba Cloud
1645
+ # ---------------------------------------------------------------------------
1646
+
1647
+ def _cloud_backtest(params: dict) -> dict:
1648
+ """
1649
+ Full ML-powered backtest via Alibaba Cloud QuantEngine.
1650
+ Falls back to local pandas backtest when cloud is unavailable.
1651
+ """
1652
+ raw = params.get("symbols", params.get("symbol", "sh600519"))
1653
+ if isinstance(raw, str):
1654
+ symbols = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
1655
+ else:
1656
+ symbols = list(raw)
1657
+
1658
+ strategy_cfg = params.get("strategy_config", {
1659
+ "model_type": params.get("model_type", "lightgbm"),
1660
+ "backtest_period_months": params.get("months", 12),
1661
+ "rebalance_freq": params.get("rebalance_freq", "weekly"),
1662
+ "top_k": params.get("top_k", 3),
1663
+ "use_enhanced_factors": True,
1664
+ "use_dynamic_position": True,
1665
+ })
1666
+ start = params.get("start", "")
1667
+ end = params.get("end", "")
1668
+ market = params.get("market", "CN")
1669
+
1670
+ if _HAS_CLOUD:
1671
+ try:
1672
+ from aria_code.aliyun_data_client import run_async
1673
+ result = run_async(
1674
+ AliyunDataClient.get().run_backtest(
1675
+ symbols, strategy_cfg,
1676
+ start_date=start, end_date=end, market=market,
1677
+ )
1678
+ )
1679
+ if result and result.get("status") in ("completed", "running"):
1680
+ out = {"success": True, "provider": "aliyun_cloud"}
1681
+ r = result.get("result") or {}
1682
+ perf = r.get("performance") or {}
1683
+ out.update({
1684
+ "backtest_id": result.get("backtest_id"),
1685
+ "status": result.get("status"),
1686
+ "total_return": perf.get("total_return"),
1687
+ "annual_return": perf.get("annualized_return"),
1688
+ "sharpe_ratio": perf.get("sharpe_ratio"),
1689
+ "max_drawdown": perf.get("max_drawdown"),
1690
+ "win_rate": perf.get("win_rate"),
1691
+ "total_trades": perf.get("total_trades"),
1692
+ "equity_curve": r.get("equity_curve"),
1693
+ })
1694
+ return out
1695
+ except Exception as exc:
1696
+ logger.debug("Cloud backtest failed: %s", exc)
1697
+
1698
+ # Local fallback: run simple pandas backtest for first symbol
1699
+ return _backtest_strategy({
1700
+ "symbol": symbols[0] if symbols else "sh600519",
1701
+ "strategy": params.get("strategy", "sma_cross"),
1702
+ "start": start or _parse_date(None, 730),
1703
+ "end": end or _today(),
1704
+ })
1705
+
1706
+
1707
+ # ---------------------------------------------------------------------------
1708
+ # Helper: format dataframe tail for display
1709
+ # ---------------------------------------------------------------------------
1710
+
1711
+ def _df_tail(df: pd.DataFrame, n: int = 5) -> List[Dict]:
1712
+ cols = [c for c in ["date", "Close", "Open", "High", "Low", "Volume"]
1713
+ if c in df.columns]
1714
+ sub = df[cols].tail(n)
1715
+ records = []
1716
+ for _, row in sub.iterrows():
1717
+ rec = {}
1718
+ for c in cols:
1719
+ v = row[c]
1720
+ try:
1721
+ if isinstance(v, (float, np.floating)):
1722
+ rec[c] = round(float(v), 4)
1723
+ elif hasattr(v, "item"):
1724
+ rec[c] = v.item()
1725
+ else:
1726
+ rec[c] = str(v)
1727
+ except Exception:
1728
+ rec[c] = str(v)
1729
+ records.append(rec)
1730
+ return records
1731
+
1732
+
1733
+
1734
+ # OpenAI/Ollama tool schemas for local finance tools
1735
+ LOCAL_FINANCE_TOOL_SCHEMAS = [
1736
+ {
1737
+ "type": "function",
1738
+ "function": {
1739
+ "name": "get_market_data",
1740
+ "description": "Get stock/ETF quotes and OHLCV history. Supports US stocks (AAPL), A-share (600519 or sh600519), ETFs, indices.",
1741
+ "parameters": {
1742
+ "type": "object",
1743
+ "properties": {
1744
+ "symbol": {"type": "string", "description": "Ticker symbol, e.g. AAPL, sh600519, BTC-USD"},
1745
+ "period": {"type": "string", "description": "yfinance period: 1d 5d 1mo 3mo 6mo 1y 2y 5y ytd max"},
1746
+ "interval": {"type": "string", "description": "Bar interval: 1m 5m 15m 30m 1h 1d 1wk 1mo"},
1747
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD (overrides period)"},
1748
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
1749
+ },
1750
+ "required": ["symbol"],
1751
+ },
1752
+ },
1753
+ },
1754
+ {
1755
+ "type": "function",
1756
+ "function": {
1757
+ "name": "get_crypto_data",
1758
+ "description": "Cryptocurrency OHLCV data. Uses ccxt if available, else yfinance.",
1759
+ "parameters": {
1760
+ "type": "object",
1761
+ "properties": {
1762
+ "symbol": {"type": "string", "description": "Pair, e.g. BTC/USDT or ETH/USDT"},
1763
+ "exchange": {"type": "string", "description": "Exchange name (binance, okx, bybit, coinbase)"},
1764
+ "timeframe": {"type": "string", "description": "Candle timeframe: 1m 5m 1h 4h 1d"},
1765
+ "limit": {"type": "integer", "description": "Number of bars"},
1766
+ },
1767
+ "required": ["symbol"],
1768
+ },
1769
+ },
1770
+ },
1771
+ {
1772
+ "type": "function",
1773
+ "function": {
1774
+ "name": "calculate_factors",
1775
+ "description": "Compute technical and quantitative factors: RSI, MACD, Bollinger, MA gaps, volatility, trend score, beta.",
1776
+ "parameters": {
1777
+ "type": "object",
1778
+ "properties": {
1779
+ "symbol": {"type": "string", "description": "Ticker symbol"},
1780
+ "period": {"type": "string", "description": "Lookback period (yfinance format)"},
1781
+ },
1782
+ "required": ["symbol"],
1783
+ },
1784
+ },
1785
+ },
1786
+ {
1787
+ "type": "function",
1788
+ "function": {
1789
+ "name": "backtest_strategy",
1790
+ "description": "Run a strategy backtest. Returns Sharpe, max drawdown, win rate, trade count, alpha vs benchmark.",
1791
+ "parameters": {
1792
+ "type": "object",
1793
+ "properties": {
1794
+ "symbol": {"type": "string"},
1795
+ "strategy": {"type": "string", "description": "sma_cross | rsi_mean_revert | momentum | buy_hold"},
1796
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD"},
1797
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
1798
+ "fast_period": {"type": "integer", "description": "Fast MA period (default 20)"},
1799
+ "slow_period": {"type": "integer", "description": "Slow MA period (default 60)"},
1800
+ "rsi_oversold": {"type": "number", "description": "RSI oversold threshold (default 30)"},
1801
+ "rsi_overbought": {"type": "number", "description": "RSI overbought threshold (default 70)"},
1802
+ "momentum_period": {"type": "integer", "description": "Momentum lookback bars (default 20)"},
1803
+ },
1804
+ "required": ["symbol"],
1805
+ },
1806
+ },
1807
+ },
1808
+ {
1809
+ "type": "function",
1810
+ "function": {
1811
+ "name": "run_portfolio_backtest",
1812
+ "description": (
1813
+ "Event-driven, multi-asset backtest. Unlike backtest_strategy (single-symbol "
1814
+ "templates), this runs a strategy across a basket with next-open fills (no "
1815
+ "look-ahead), commission+slippage, and an equal-weight buy-and-hold benchmark. "
1816
+ "Returns total/annual return, Sharpe, max drawdown, win rate, trade count, and "
1817
+ "alpha vs buy-hold. Use for portfolio/multi-symbol strategy evaluation."
1818
+ ),
1819
+ "parameters": {
1820
+ "type": "object",
1821
+ "properties": {
1822
+ "symbols": {"type": "string", "description": "One or more tickers, space/comma separated (e.g. 'AAPL MSFT 600519')"},
1823
+ "strategy": {"type": "string", "description": "sma_cross | buy_hold (default sma_cross)"},
1824
+ "days": {"type": "integer", "description": "Lookback window in days (default 365)"},
1825
+ "fast": {"type": "integer", "description": "Fast MA period for sma_cross (default 20)"},
1826
+ "slow": {"type": "integer", "description": "Slow MA period for sma_cross (default 60)"},
1827
+ "starting_cash": {"type": "number", "description": "Initial capital (default 100000)"},
1828
+ "commission": {"type": "number", "description": "Per-trade commission rate (default 0.0005)"},
1829
+ "slippage": {"type": "number", "description": "Per-trade slippage rate (default 0.0005)"},
1830
+ },
1831
+ "required": ["symbols"],
1832
+ },
1833
+ },
1834
+ },
1835
+ {
1836
+ "type": "function",
1837
+ "function": {
1838
+ "name": "get_risk_metrics",
1839
+ "description": "Portfolio / stock risk metrics: VaR, CVaR, drawdown, Sharpe, Sortino, Calmar, skewness.",
1840
+ "parameters": {
1841
+ "type": "object",
1842
+ "properties": {
1843
+ "symbol": {"type": "string"},
1844
+ "period": {"type": "string", "description": "Data lookback (default 1y)"},
1845
+ "confidence": {"type": "number", "description": "VaR confidence level (default 0.95)"},
1846
+ },
1847
+ "required": ["symbol"],
1848
+ },
1849
+ },
1850
+ },
1851
+ {
1852
+ "type": "function",
1853
+ "function": {
1854
+ "name": "optimize_positions",
1855
+ "description": "Portfolio weight optimisation using Markowitz / max-Sharpe / min-variance.",
1856
+ "parameters": {
1857
+ "type": "object",
1858
+ "properties": {
1859
+ "symbols": {"type": "array", "items": {"type": "string"}, "description": "List of ticker symbols"},
1860
+ "method": {"type": "string", "description": "max_sharpe | min_var | equal_weight"},
1861
+ "period": {"type": "string", "description": "History period (default 1y)"},
1862
+ "risk_free_rate": {"type": "number", "description": "Annual risk-free rate (default 0.04)"},
1863
+ },
1864
+ "required": ["symbols"],
1865
+ },
1866
+ },
1867
+ },
1868
+ {
1869
+ "type": "function",
1870
+ "function": {
1871
+ "name": "get_sector_performance",
1872
+ "description": "Sector performance ranking. market='cn' uses akshare industry data; market='us' uses SPDR ETFs.",
1873
+ "parameters": {
1874
+ "type": "object",
1875
+ "properties": {
1876
+ "market": {"type": "string", "description": "cn | us (default cn)"},
1877
+ },
1878
+ "required": [],
1879
+ },
1880
+ },
1881
+ },
1882
+ {
1883
+ "type": "function",
1884
+ "function": {
1885
+ "name": "get_northbound_flow",
1886
+ "description": "北向资金 (Shanghai-HK / Shenzhen-HK Connect) net buy amount in 亿元 via akshare.",
1887
+ "parameters": {
1888
+ "type": "object",
1889
+ "properties": {
1890
+ "days": {"type": "integer", "description": "Number of trading days to show (default 10)"},
1891
+ },
1892
+ "required": [],
1893
+ },
1894
+ },
1895
+ },
1896
+ {
1897
+ "type": "function",
1898
+ "function": {
1899
+ "name": "screen_ashare",
1900
+ "description": "A股选股筛选器. Filters by PE, market cap, ST/退市 exclusion, momentum ranking.",
1901
+ "parameters": {
1902
+ "type": "object",
1903
+ "properties": {
1904
+ "max_pe": {"type": "number", "description": "Max dynamic PE ratio (default 50)"},
1905
+ "min_market_cap_yi": {"type": "number", "description": "Min market cap in 亿元 (default 0)"},
1906
+ "limit": {"type": "integer", "description": "Max stocks to return (default 20)"},
1907
+ },
1908
+ "required": [],
1909
+ },
1910
+ },
1911
+ },
1912
+ {
1913
+ "type": "function",
1914
+ "function": {
1915
+ "name": "get_limit_up_pool",
1916
+ "description": "Today's A股 limit-up stock pool (涨停板) via akshare. Includes consecutive limit-up count.",
1917
+ "parameters": {
1918
+ "type": "object",
1919
+ "properties": {
1920
+ "date": {"type": "string", "description": "Date YYYY-MM-DD (default today)"},
1921
+ },
1922
+ "required": [],
1923
+ },
1924
+ },
1925
+ },
1926
+ {
1927
+ "type": "function",
1928
+ "function": {
1929
+ "name": "get_market_indices",
1930
+ "description": "Major global market indices: S&P 500, NASDAQ, 上证综指, Nikkei, Gold, BTC, etc.",
1931
+ "parameters": {"type": "object", "properties": {}, "required": []},
1932
+ },
1933
+ },
1934
+ {
1935
+ "type": "function",
1936
+ "function": {
1937
+ "name": "analyze_news",
1938
+ "description": "Fetch and sentiment-score recent news for a stock or topic.",
1939
+ "parameters": {
1940
+ "type": "object",
1941
+ "properties": {
1942
+ "symbol": {"type": "string", "description": "Stock code or company name"},
1943
+ "limit": {"type": "integer", "description": "Number of articles (default 5)"},
1944
+ },
1945
+ "required": ["symbol"],
1946
+ },
1947
+ },
1948
+ },
1949
+ {
1950
+ "type": "function",
1951
+ "function": {
1952
+ "name": "get_bonds_data",
1953
+ "description": "US Treasury yield curve (2Y, 5Y, 10Y, 30Y) and 10Y-2Y spread.",
1954
+ "parameters": {"type": "object", "properties": {}, "required": []},
1955
+ },
1956
+ },
1957
+ {
1958
+ "type": "function",
1959
+ "function": {
1960
+ "name": "get_commodities_data",
1961
+ "description": (
1962
+ "Commodity spot/futures price, 52-week range and 1-year return. "
1963
+ "Supports: gold, silver, oil, crude, brent, natgas, copper, wheat, corn, soybean, coffee, "
1964
+ "cotton, cattle, lumber, 黄金, 原油, 铜, etc. "
1965
+ "Also accepts yfinance tickers directly (e.g. GC=F, CL=F, NG=F)."
1966
+ ),
1967
+ "parameters": {
1968
+ "type": "object",
1969
+ "properties": {
1970
+ "commodity": {
1971
+ "type": "string",
1972
+ "description": "Commodity name (gold, oil, copper…) or yfinance ticker (GC=F)"
1973
+ },
1974
+ },
1975
+ "required": ["commodity"],
1976
+ },
1977
+ },
1978
+ },
1979
+ {
1980
+ "type": "function",
1981
+ "function": {
1982
+ "name": "get_futures_data",
1983
+ "description": "Equity index and other futures: S&P (ES=F), NASDAQ (NQ=F), Nikkei, VIX, etc.",
1984
+ "parameters": {
1985
+ "type": "object",
1986
+ "properties": {
1987
+ "contract": {
1988
+ "type": "string",
1989
+ "description": "Futures contract name or ticker: SP500, NQ, DOW, VIX, ES=F, NQ=F…"
1990
+ },
1991
+ },
1992
+ "required": ["contract"],
1993
+ },
1994
+ },
1995
+ },
1996
+ # ── New cloud-backed tools ────────────────────────────────────────────
1997
+ {
1998
+ "type": "function",
1999
+ "function": {
2000
+ "name": "get_ai_signal",
2001
+ "description": (
2002
+ "AI-powered trading signal (BUY/SELL/HOLD) with confidence, reasoning, stop-loss and "
2003
+ "take-profit levels. Uses Alibaba Cloud DeepSeek + QuantEngine. "
2004
+ "Falls back to local factor-based signal when cloud unavailable."
2005
+ ),
2006
+ "parameters": {
2007
+ "type": "object",
2008
+ "properties": {
2009
+ "symbol": {"type": "string", "description": "Stock code, e.g. sh600519, AAPL"},
2010
+ "market": {"type": "string", "description": "CN | US (auto-detected from symbol)"},
2011
+ },
2012
+ "required": ["symbol"],
2013
+ },
2014
+ },
2015
+ },
2016
+ {
2017
+ "type": "function",
2018
+ "function": {
2019
+ "name": "get_market_insights",
2020
+ "description": (
2021
+ "AI narrative market insights for a basket of stocks. Returns sentiment, key risks, "
2022
+ "opportunities and macro context. Powered by Alibaba Cloud AI."
2023
+ ),
2024
+ "parameters": {
2025
+ "type": "object",
2026
+ "properties": {
2027
+ "symbols": {
2028
+ "type": "array",
2029
+ "items": {"type": "string"},
2030
+ "description": "List of stock codes, e.g. ['sh600519', 'sz000858']"
2031
+ },
2032
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2033
+ },
2034
+ "required": ["symbols"],
2035
+ },
2036
+ },
2037
+ },
2038
+ {
2039
+ "type": "function",
2040
+ "function": {
2041
+ "name": "get_predictions",
2042
+ "description": (
2043
+ "ML model return predictions for a list of stocks. "
2044
+ "Uses Alibaba Cloud LightGBM/XGBoost ensemble. "
2045
+ "Returns predicted_return and confidence for each symbol."
2046
+ ),
2047
+ "parameters": {
2048
+ "type": "object",
2049
+ "properties": {
2050
+ "symbols": {
2051
+ "type": "array",
2052
+ "items": {"type": "string"},
2053
+ "description": "List of stock codes"
2054
+ },
2055
+ "prediction_days": {"type": "integer", "description": "Forecast horizon in days (default 5)"},
2056
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2057
+ },
2058
+ "required": ["symbols"],
2059
+ },
2060
+ },
2061
+ },
2062
+ {
2063
+ "type": "function",
2064
+ "function": {
2065
+ "name": "cloud_backtest",
2066
+ "description": (
2067
+ "Full ML-powered backtest on Alibaba Cloud QuantEngine. "
2068
+ "Supports rebalance_freq (daily/weekly/monthly), dynamic position sizing, "
2069
+ "model_type (lightgbm/xgboost/ensemble). Falls back to local pandas backtest."
2070
+ ),
2071
+ "parameters": {
2072
+ "type": "object",
2073
+ "properties": {
2074
+ "symbols": {
2075
+ "type": "array",
2076
+ "items": {"type": "string"},
2077
+ "description": "List of stock codes for backtest universe"
2078
+ },
2079
+ "model_type": {"type": "string", "description": "lightgbm | xgboost | ensemble (default lightgbm)"},
2080
+ "months": {"type": "integer", "description": "Backtest period in months (default 12)"},
2081
+ "rebalance_freq": {"type": "string", "description": "daily | weekly | monthly (default weekly)"},
2082
+ "top_k": {"type": "integer", "description": "Top-K stocks to hold per period (default 3)"},
2083
+ "start": {"type": "string", "description": "Start date YYYY-MM-DD"},
2084
+ "end": {"type": "string", "description": "End date YYYY-MM-DD"},
2085
+ "market": {"type": "string", "description": "CN | US (default CN)"},
2086
+ },
2087
+ "required": [],
2088
+ },
2089
+ },
2090
+ },
2091
+ # ── web_search ────────────────────────────────────────────────────────────
2092
+ {
2093
+ "type": "function",
2094
+ "function": {
2095
+ "name": "web_search",
2096
+ "description": (
2097
+ "Search the web for current information. "
2098
+ "USE THIS PROACTIVELY when the user asks about: recent news, latest earnings, "
2099
+ "new IPO stocks or unverified tickers, price targets, analyst upgrades/downgrades, "
2100
+ "M&A deals, regulatory decisions, macro events, or anything that may have changed "
2101
+ "after your training cutoff. Do NOT rely on training data for current events — "
2102
+ "always search first. Chain with web_fetch to read full articles."
2103
+ ),
2104
+ "parameters": {
2105
+ "type": "object",
2106
+ "properties": {
2107
+ "query": {"type": "string", "description": "Search query, include ticker and topic, e.g. 'AAPL earnings Q1 2026'"},
2108
+ "max_results": {"type": "integer", "description": "Number of results (default 5, max 10)"},
2109
+ },
2110
+ "required": ["query"],
2111
+ },
2112
+ },
2113
+ },
2114
+ # ── web_fetch ─────────────────────────────────────────────────────────────
2115
+ {
2116
+ "type": "function",
2117
+ "function": {
2118
+ "name": "web_fetch",
2119
+ "description": (
2120
+ "Fetch a URL and return the page text. Use after web_search to read full article content, "
2121
+ "SEC filings, earnings reports, or any webpage. Automatically strips HTML tags."
2122
+ ),
2123
+ "parameters": {
2124
+ "type": "object",
2125
+ "properties": {
2126
+ "url": {"type": "string", "description": "Full URL to fetch"},
2127
+ "timeout": {"type": "integer", "description": "Timeout seconds (default 15)"},
2128
+ },
2129
+ "required": ["url"],
2130
+ },
2131
+ },
2132
+ },
2133
+ # ── get_forex_data ────────────────────────────────────────────────────────
2134
+ {
2135
+ "type": "function",
2136
+ "function": {
2137
+ "name": "get_forex_data",
2138
+ "description": "Get exchange rate data for currency pairs (e.g. USD/CNY, EUR/USD, USD/JPY). Returns OHLCV and current rate.",
2139
+ "parameters": {
2140
+ "type": "object",
2141
+ "properties": {
2142
+ "pair": {"type": "string", "description": "Currency pair e.g. USDCNY=X, EURUSD=X, USDJPY=X"},
2143
+ "period": {"type": "string", "description": "1d | 5d | 1mo | 3mo | 1y (default 1mo)"},
2144
+ },
2145
+ "required": ["pair"],
2146
+ },
2147
+ },
2148
+ },
2149
+ # ── get_options_chain ─────────────────────────────────────────────────────
2150
+ {
2151
+ "type": "function",
2152
+ "function": {
2153
+ "name": "get_options_chain",
2154
+ "description": "Retrieve options chain for a US stock: calls & puts with strike, expiry, IV, delta, volume, OI. Use for options strategy analysis.",
2155
+ "parameters": {
2156
+ "type": "object",
2157
+ "properties": {
2158
+ "symbol": {"type": "string", "description": "US stock ticker e.g. AAPL, TSLA, NVDA"},
2159
+ "expiry": {"type": "string", "description": "Expiration date YYYY-MM-DD or leave blank for nearest"},
2160
+ "option_type": {"type": "string", "description": "call | put | both (default both)"},
2161
+ },
2162
+ "required": ["symbol"],
2163
+ },
2164
+ },
2165
+ },
2166
+ # ── peer_comparison ───────────────────────────────────────────────────────
2167
+ {
2168
+ "type": "function",
2169
+ "function": {
2170
+ "name": "peer_comparison",
2171
+ "description": "Compare a stock against sector peers on valuation (PE/PB), profitability (ROE), market cap. Automatically selects peers if not specified.",
2172
+ "parameters": {
2173
+ "type": "object",
2174
+ "properties": {
2175
+ "symbol": {"type": "string", "description": "Target stock ticker"},
2176
+ "peers": {"type": "array", "items": {"type": "string"}, "description": "Peer tickers (optional, auto-selected if omitted)"},
2177
+ },
2178
+ "required": ["symbol"],
2179
+ },
2180
+ },
2181
+ },
2182
+ # ── piotroski_fscore ──────────────────────────────────────────────────────
2183
+ {
2184
+ "type": "function",
2185
+ "function": {
2186
+ "name": "piotroski_fscore",
2187
+ "description": "Calculate Piotroski F-Score (0-9) for financial health assessment. Score ≥7 = strong, ≤2 = weak. Use for fundamental stock screening.",
2188
+ "parameters": {
2189
+ "type": "object",
2190
+ "properties": {
2191
+ "symbol": {"type": "string", "description": "Stock ticker"},
2192
+ },
2193
+ "required": ["symbol"],
2194
+ },
2195
+ },
2196
+ },
2197
+ # ── altman_zscore ─────────────────────────────────────────────────────────
2198
+ {
2199
+ "type": "function",
2200
+ "function": {
2201
+ "name": "altman_zscore",
2202
+ "description": "Calculate Altman Z-Score for bankruptcy risk. Z>2.99 = safe, 1.81-2.99 = grey zone, <1.81 = distress. Use when user asks about company financial risk.",
2203
+ "parameters": {
2204
+ "type": "object",
2205
+ "properties": {
2206
+ "symbol": {"type": "string", "description": "Stock ticker"},
2207
+ },
2208
+ "required": ["symbol"],
2209
+ },
2210
+ },
2211
+ },
2212
+ # ── calculate_ichimoku ────────────────────────────────────────────────────
2213
+ {
2214
+ "type": "function",
2215
+ "function": {
2216
+ "name": "calculate_ichimoku",
2217
+ "description": "Calculate Ichimoku Cloud indicators (Tenkan, Kijun, Senkou A/B, Chikou). Provides cloud support/resistance levels and trend signals.",
2218
+ "parameters": {
2219
+ "type": "object",
2220
+ "properties": {
2221
+ "symbol": {"type": "string", "description": "Stock or crypto ticker"},
2222
+ "period": {"type": "string", "description": "Price history period: 3mo | 6mo | 1y (default 6mo)"},
2223
+ },
2224
+ "required": ["symbol"],
2225
+ },
2226
+ },
2227
+ },
2228
+ # ── get_fear_greed_index ──────────────────────────────────────────────────
2229
+ {
2230
+ "type": "function",
2231
+ "function": {
2232
+ "name": "get_fear_greed_index",
2233
+ "description": "Get CNN Fear & Greed Index (0-100) for overall market sentiment. Use when user asks about market sentiment, risk appetite, or broad market mood.",
2234
+ "parameters": {
2235
+ "type": "object",
2236
+ "properties": {},
2237
+ "required": [],
2238
+ },
2239
+ },
2240
+ },
2241
+ # ── get_funding_rates ─────────────────────────────────────────────────────
2242
+ {
2243
+ "type": "function",
2244
+ "function": {
2245
+ "name": "get_funding_rates",
2246
+ "description": "Get perpetual futures funding rates for crypto assets (BTC, ETH, etc.) from major exchanges. Positive rate = longs pay shorts (bullish); negative = shorts pay longs (bearish).",
2247
+ "parameters": {
2248
+ "type": "object",
2249
+ "properties": {
2250
+ "symbol": {"type": "string", "description": "Crypto symbol e.g. BTC, ETH, SOL"},
2251
+ },
2252
+ "required": ["symbol"],
2253
+ },
2254
+ },
2255
+ },
2256
+ # ── get_funding_rates_compare ─────────────────────────────────────────────
2257
+ # The handler has been in LOCAL_FINANCE_TOOL_REGISTRY all along with no
2258
+ # schema beside it, so it was registered, callable, and invisible: the
2259
+ # model was never told it exists.
2260
+ {
2261
+ "type": "function",
2262
+ "function": {
2263
+ "name": "get_funding_rates_compare",
2264
+ "description": (
2265
+ "Compare perpetual funding rates for the same assets across Binance, OKX "
2266
+ "and Bybit in one call. Use this to spot cross-exchange arbitrage: a spread "
2267
+ "wider than about 0.02% between venues is worth attention. Requires ccxt."
2268
+ ),
2269
+ "parameters": {
2270
+ "type": "object",
2271
+ "properties": {
2272
+ "symbols": {
2273
+ "type": "array",
2274
+ "items": {"type": "string"},
2275
+ "description": "Perpetual pairs, e.g. [\"BTC/USDT\", \"ETH/USDT\"]. Defaults to BTC, ETH and SOL.",
2276
+ },
2277
+ },
2278
+ "required": [],
2279
+ },
2280
+ },
2281
+ },
2282
+ # ── walk_forward_backtest ─────────────────────────────────────────────────
2283
+ {
2284
+ "type": "function",
2285
+ "function": {
2286
+ "name": "walk_forward_backtest",
2287
+ "description": "Run walk-forward validation backtest: train on rolling windows then test out-of-sample to avoid overfitting. More robust than simple backtest.",
2288
+ "parameters": {
2289
+ "type": "object",
2290
+ "properties": {
2291
+ "symbol": {"type": "string", "description": "Stock ticker"},
2292
+ "strategy": {"type": "string", "description": "Strategy name: sma_cross | rsi_reversal | macd_trend"},
2293
+ "train_months": {"type": "integer", "description": "Training window in months (default 12)"},
2294
+ "test_months": {"type": "integer", "description": "Test window in months (default 3)"},
2295
+ },
2296
+ "required": ["symbol", "strategy"],
2297
+ },
2298
+ },
2299
+ },
2300
+ ]
2301
+
2302
+
2303
+ # ---------------------------------------------------------------------------
2304
+ # 19. Piotroski F-Score (基本面质量评分, 0–9)
2305
+ # ---------------------------------------------------------------------------
2306
+
2307
+ def _piotroski_fscore(params: dict) -> dict:
2308
+ """
2309
+ Piotroski F-Score: 9项二元信号综合判断财务质量。
2310
+ ≥7 = 高质量(做多信号), ≤3 = 低质量(做空信号), 4-6 = 中性。
2311
+ """
2312
+ symbol = params.get("symbol", "AAPL")
2313
+ if not _HAS_YF:
2314
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2315
+
2316
+ try:
2317
+ tkr = yf.Ticker(symbol)
2318
+ info = tkr.info or {}
2319
+ bs_annual = tkr.balance_sheet if hasattr(tkr, "balance_sheet") else None
2320
+ is_annual = tkr.income_stmt if hasattr(tkr, "income_stmt") else None
2321
+ cf_annual = tkr.cashflow if hasattr(tkr, "cashflow") else None
2322
+
2323
+ def _get(df, row, col=0):
2324
+ try:
2325
+ if df is None or df.empty: return None
2326
+ matches = [r for r in df.index if row.lower() in str(r).lower()]
2327
+ if not matches: return None
2328
+ val = df.loc[matches[0]].iloc[col]
2329
+ return float(val) if val is not None and str(val) not in ("nan","None") else None
2330
+ except Exception:
2331
+ return None
2332
+
2333
+ scores: Dict[str, Any] = {}
2334
+
2335
+ # ── Profitability (4 signals) ──────────────────────────────────────
2336
+ roa = info.get("returnOnAssets") or _get(is_annual, "Net Income")
2337
+ scores["F1_ROA_positive"] = int((roa or 0) > 0)
2338
+
2339
+ cfo = _get(cf_annual, "Operating Cash Flow") or _get(cf_annual, "Total Cash From Operating")
2340
+ scores["F2_CFO_positive"] = int((cfo or 0) > 0)
2341
+
2342
+ # ROA change (current vs prior year)
2343
+ net_inc_cur = _get(is_annual, "Net Income", 0)
2344
+ net_inc_prev = _get(is_annual, "Net Income", 1)
2345
+ ta_cur = _get(bs_annual, "Total Assets", 0)
2346
+ ta_prev = _get(bs_annual, "Total Assets", 1)
2347
+ roa_cur = (net_inc_cur / ta_cur) if (ta_cur and net_inc_cur is not None) else None
2348
+ roa_prev = (net_inc_prev / ta_prev) if (ta_prev and net_inc_prev is not None) else None
2349
+ scores["F3_ROA_increasing"] = int(roa_cur > roa_prev) if (roa_cur is not None and roa_prev is not None) else 0
2350
+
2351
+ # Accruals: CFO > ROA × Total Assets
2352
+ scores["F4_CFO_gt_ROA"] = int((cfo or 0) > (roa or 0) * (ta_cur or 1))
2353
+
2354
+ # ── Leverage, Liquidity (3 signals) ───────────────────────────────
2355
+ ltd_cur = _get(bs_annual, "Long Term Debt", 0)
2356
+ ltd_prev = _get(bs_annual, "Long Term Debt", 1)
2357
+ scores["F5_Leverage_lower"] = int((ltd_cur or 0) < (ltd_prev or 0)) if ltd_prev is not None else 0
2358
+
2359
+ ca_cur = _get(bs_annual, "Current Assets", 0)
2360
+ ca_prev = _get(bs_annual, "Current Assets", 1)
2361
+ cl_cur = _get(bs_annual, "Current Liabilities", 0) or _get(bs_annual, "Total Current Liabilities", 0)
2362
+ cl_prev = _get(bs_annual, "Current Liabilities", 1) or _get(bs_annual, "Total Current Liabilities", 1)
2363
+ cr_cur = (ca_cur / cl_cur) if (cl_cur and ca_cur) else None
2364
+ cr_prev = (ca_prev / cl_prev) if (cl_prev and ca_prev) else None
2365
+ scores["F6_CurrentRatio_up"] = int(cr_cur > cr_prev) if (cr_cur and cr_prev) else 0
2366
+
2367
+ # No dilution: shares outstanding not increasing
2368
+ shares_cur = info.get("sharesOutstanding") or _get(bs_annual, "Ordinary Shares Number", 0)
2369
+ shares_prev = _get(bs_annual, "Ordinary Shares Number", 1)
2370
+ scores["F7_NoDilution"] = int((shares_cur or 1) <= (shares_prev or 1)) if shares_prev else 1
2371
+
2372
+ # ── Operating Efficiency (2 signals) ──────────────────────────────
2373
+ rev_cur = _get(is_annual, "Total Revenue", 0)
2374
+ rev_prev = _get(is_annual, "Total Revenue", 1)
2375
+ gp_cur = _get(is_annual, "Gross Profit", 0)
2376
+ gp_prev = _get(is_annual, "Gross Profit", 1)
2377
+ gm_cur = (gp_cur / rev_cur) if (rev_cur and gp_cur) else None
2378
+ gm_prev = (gp_prev / rev_prev) if (rev_prev and gp_prev) else None
2379
+ scores["F8_GrossMargin_up"] = int(gm_cur > gm_prev) if (gm_cur is not None and gm_prev is not None) else 0
2380
+
2381
+ at_cur = (rev_cur / ta_cur) if (ta_cur and rev_cur) else None
2382
+ at_prev = (rev_prev / ta_prev) if (ta_prev and rev_prev) else None
2383
+ scores["F9_AssetTurnover_up"] = int(at_cur > at_prev) if (at_cur is not None and at_prev is not None) else 0
2384
+
2385
+ fscore = sum(scores.values())
2386
+
2387
+ if fscore >= 7:
2388
+ verdict, color = "高质量 — 做多信号", "bullish"
2389
+ elif fscore <= 3:
2390
+ verdict, color = "低质量 — 做空信号", "bearish"
2391
+ else:
2392
+ verdict, color = "中性", "neutral"
2393
+
2394
+ return {
2395
+ "success": True,
2396
+ "symbol": symbol,
2397
+ "f_score": fscore,
2398
+ "verdict": verdict,
2399
+ "signal": color,
2400
+ "scores": scores,
2401
+ "provider": "yfinance",
2402
+ }
2403
+ except Exception as e:
2404
+ return {"success": False, "error": str(e)}
2405
+
2406
+
2407
+ # ---------------------------------------------------------------------------
2408
+ # 20. Altman Z-Score (破产风险预测)
2409
+ # ---------------------------------------------------------------------------
2410
+
2411
+ def _altman_zscore(params: dict) -> dict:
2412
+ """
2413
+ Altman Z''-Score(适合非制造业,使用 4 变量版)。
2414
+ Z > 2.6 = 安全区,1.1–2.6 = 灰色区,< 1.1 = 破产风险。
2415
+ """
2416
+ symbol = params.get("symbol", "AAPL")
2417
+ if not _HAS_YF:
2418
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2419
+
2420
+ try:
2421
+ tkr = yf.Ticker(symbol)
2422
+ info = tkr.info or {}
2423
+ bs = tkr.balance_sheet if hasattr(tkr, "balance_sheet") else None
2424
+ is_ = tkr.income_stmt if hasattr(tkr, "income_stmt") else None
2425
+
2426
+ def _g(df, row, col=0):
2427
+ try:
2428
+ if df is None or df.empty: return None
2429
+ m = [r for r in df.index if row.lower() in str(r).lower()]
2430
+ if not m: return None
2431
+ v = df.loc[m[0]].iloc[col]
2432
+ return float(v) if str(v) not in ("nan","None","") else None
2433
+ except Exception:
2434
+ return None
2435
+
2436
+ ta = _g(bs, "Total Assets") or info.get("totalAssets")
2437
+ tl = (_g(bs, "Total Liabilities Net Minority Interest") or
2438
+ _g(bs, "Total Liabilities"))
2439
+ ca = _g(bs, "Current Assets")
2440
+ cl = (_g(bs, "Total Current Liabilities") or _g(bs, "Current Liabilities"))
2441
+ re = _g(bs, "Retained Earnings")
2442
+ ebit = _g(is_, "EBIT") or _g(is_, "Operating Income")
2443
+ revenue = _g(is_, "Total Revenue")
2444
+ market_cap = info.get("marketCap")
2445
+ bv_equity = info.get("bookValue", 0) or 0
2446
+ shares_out = info.get("sharesOutstanding", 0) or 0
2447
+ book_equity = bv_equity * shares_out
2448
+
2449
+ if not ta or ta == 0:
2450
+ return {"success": False, "error": "无法获取总资产数据"}
2451
+
2452
+ # Working capital / Total Assets (X1)
2453
+ wc = (ca or 0) - (cl or 0)
2454
+ x1 = wc / ta
2455
+
2456
+ # Retained Earnings / Total Assets (X2)
2457
+ x2 = (re or 0) / ta
2458
+
2459
+ # EBIT / Total Assets (X3)
2460
+ x3 = (ebit or 0) / ta
2461
+
2462
+ # Book/Market Value of Equity / Total Liabilities (X4 — Z'' uses book)
2463
+ bv = book_equity or (market_cap or 0)
2464
+ x4 = bv / (tl or 1)
2465
+
2466
+ # Z'' = 6.56·X1 + 3.26·X2 + 6.72·X3 + 1.05·X4
2467
+ z = 6.56 * x1 + 3.26 * x2 + 6.72 * x3 + 1.05 * x4
2468
+ z = round(z, 3)
2469
+
2470
+ if z > 2.6:
2471
+ zone = "安全区"
2472
+ risk = "low"
2473
+ elif z > 1.1:
2474
+ zone = "灰色区(不确定)"
2475
+ risk = "medium"
2476
+ else:
2477
+ zone = "破产风险区"
2478
+ risk = "high"
2479
+
2480
+ return {
2481
+ "success": True,
2482
+ "symbol": symbol,
2483
+ "z_score": z,
2484
+ "zone": zone,
2485
+ "risk": risk,
2486
+ "components": {
2487
+ "X1_working_capital_ratio": round(x1, 4),
2488
+ "X2_retained_earnings_ratio": round(x2, 4),
2489
+ "X3_ebit_ratio": round(x3, 4),
2490
+ "X4_equity_to_debt": round(x4, 4),
2491
+ },
2492
+ "formula": "Z'' = 6.56·X1 + 3.26·X2 + 6.72·X3 + 1.05·X4",
2493
+ "provider": "yfinance",
2494
+ }
2495
+ except Exception as e:
2496
+ return {"success": False, "error": str(e)}
2497
+
2498
+
2499
+ # ---------------------------------------------------------------------------
2500
+ # 21. Options Chain (期权链)
2501
+ # ---------------------------------------------------------------------------
2502
+
2503
+ def _get_options_chain(params: dict) -> dict:
2504
+ """
2505
+ 获取股票期权链(via yfinance)。
2506
+ 返回最近到期日的 calls + puts 列表。
2507
+ """
2508
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2509
+ expiry = params.get("expiry", "") # "YYYY-MM-DD" or "" = nearest
2510
+ opt_type = params.get("type", "both").lower() # "calls" | "puts" | "both"
2511
+ limit = int(params.get("limit", 15))
2512
+
2513
+ if not _HAS_YF:
2514
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2515
+
2516
+ try:
2517
+ tkr = yf.Ticker(symbol)
2518
+ dates = tkr.options
2519
+ if not dates:
2520
+ return {"success": False, "error": f"{symbol} 无可用期权数据"}
2521
+
2522
+ if expiry and expiry in dates:
2523
+ exp = expiry
2524
+ else:
2525
+ exp = dates[0] # nearest expiry
2526
+
2527
+ chain = tkr.option_chain(exp)
2528
+ price = (tkr.info or {}).get("regularMarketPrice") or (tkr.info or {}).get("currentPrice") or 0
2529
+
2530
+ def _fmt(df):
2531
+ if df is None or df.empty:
2532
+ return []
2533
+ cols = [c for c in ["strike","lastPrice","bid","ask","volume","openInterest",
2534
+ "impliedVolatility","inTheMoney"] if c in df.columns]
2535
+ df = df[cols].head(limit)
2536
+ rows = df.to_dict("records")
2537
+ for r in rows:
2538
+ iv = r.get("impliedVolatility")
2539
+ r["iv_pct"] = round(iv * 100, 1) if iv else None
2540
+ return rows
2541
+
2542
+ result: Dict[str, Any] = {
2543
+ "success": True,
2544
+ "symbol": symbol,
2545
+ "price": price,
2546
+ "expiry": exp,
2547
+ "all_expiries": list(dates[:6]),
2548
+ "provider": "yfinance",
2549
+ }
2550
+ if opt_type in ("calls", "both"):
2551
+ result["calls"] = _fmt(chain.calls)
2552
+ if opt_type in ("puts", "both"):
2553
+ result["puts"] = _fmt(chain.puts)
2554
+
2555
+ return result
2556
+ except Exception as e:
2557
+ return {"success": False, "error": str(e)}
2558
+
2559
+
2560
+ # ---------------------------------------------------------------------------
2561
+ # 22. Ichimoku Cloud (一目均衡表)
2562
+ # ---------------------------------------------------------------------------
2563
+
2564
+ def _calculate_ichimoku(params: dict) -> dict:
2565
+ """
2566
+ 一目均衡表指标计算。
2567
+ 返回 Tenkan-sen(转换线), Kijun-sen(基准线), Senkou Span A/B(先行带),
2568
+ Chikou(迟行线), 以及云层厚度与当前信号。
2569
+ """
2570
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2571
+ period = params.get("period", "6mo")
2572
+
2573
+ if not _HAS_YF:
2574
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2575
+
2576
+ try:
2577
+ df = yf.Ticker(symbol).history(period=period)
2578
+ if df is None or len(df) < 52:
2579
+ return {"success": False, "error": "历史数据不足(至少需要 52 天)"}
2580
+
2581
+ high = df["High"].astype(float)
2582
+ low = df["Low"].astype(float)
2583
+ close= df["Close"].astype(float)
2584
+
2585
+ def _mid(h, l, n):
2586
+ return (h.rolling(n).max() + l.rolling(n).min()) / 2
2587
+
2588
+ tenkan = _mid(high, low, 9)
2589
+ kijun = _mid(high, low, 26)
2590
+ senkou_a = ((tenkan + kijun) / 2).shift(26)
2591
+ senkou_b = _mid(high, low, 52).shift(26)
2592
+ chikou = close.shift(-26)
2593
+
2594
+ t = round(float(tenkan.iloc[-1]), 3)
2595
+ k = round(float(kijun.iloc[-1]), 3)
2596
+ sa = round(float(senkou_a.iloc[-1]), 3) if not pd.isna(senkou_a.iloc[-1]) else None
2597
+ sb = round(float(senkou_b.iloc[-1]), 3) if not pd.isna(senkou_b.iloc[-1]) else None
2598
+ c = round(float(close.iloc[-1]), 3)
2599
+ ck = round(float(chikou.iloc[-53]) if len(chikou) > 53 else float(chikou.iloc[0]), 3)
2600
+
2601
+ # Signal
2602
+ above_cloud = sa is not None and sb is not None and c > max(sa, sb)
2603
+ below_cloud = sa is not None and sb is not None and c < min(sa, sb)
2604
+ bullish_tk = t > k
2605
+ cloud_color = "绿云(多)" if (sa and sb and sa > sb) else "红云(空)"
2606
+
2607
+ if above_cloud and bullish_tk:
2608
+ signal = "强势多头"
2609
+ elif above_cloud:
2610
+ signal = "偏多(价格在云上方)"
2611
+ elif below_cloud and not bullish_tk:
2612
+ signal = "强势空头"
2613
+ elif below_cloud:
2614
+ signal = "偏空(价格在云下方)"
2615
+ else:
2616
+ signal = "震荡(价格在云内)"
2617
+
2618
+ return {
2619
+ "success": True,
2620
+ "symbol": symbol,
2621
+ "price": c,
2622
+ "tenkan": t,
2623
+ "kijun": k,
2624
+ "senkou_a": sa,
2625
+ "senkou_b": sb,
2626
+ "chikou": ck,
2627
+ "cloud_color": cloud_color,
2628
+ "cloud_thickness": round(abs((sa or 0) - (sb or 0)), 3),
2629
+ "signal": signal,
2630
+ "above_cloud": above_cloud,
2631
+ "below_cloud": below_cloud,
2632
+ "tk_cross": "金叉(多)" if bullish_tk else "死叉(空)",
2633
+ "provider": "yfinance",
2634
+ }
2635
+ except Exception as e:
2636
+ return {"success": False, "error": str(e)}
2637
+
2638
+
2639
+ # ---------------------------------------------------------------------------
2640
+ # 23. Crypto Fear & Greed Index + Funding Rates
2641
+ # ---------------------------------------------------------------------------
2642
+
2643
+ def _get_fear_greed_index(params: dict) -> dict:
2644
+ """加密货币恐惧贪婪指数(来源: alternative.me,无需 API Key)。"""
2645
+ try:
2646
+ import urllib.request
2647
+ import json as _json
2648
+ with urllib.request.urlopen(
2649
+ "https://api.alternative.me/fng/?limit=7&format=json", timeout=6
2650
+ ) as resp:
2651
+ data = _json.loads(resp.read().decode())
2652
+ items = data.get("data", [])
2653
+ if not items:
2654
+ return {"success": False, "error": "No data returned"}
2655
+ latest = items[0]
2656
+ value = int(latest.get("value", 0))
2657
+ label_en = latest.get("value_classification", "")
2658
+ label_cn_map = {
2659
+ "Extreme Fear": "极度恐惧",
2660
+ "Fear": "恐惧",
2661
+ "Neutral": "中性",
2662
+ "Greed": "贪婪",
2663
+ "Extreme Greed":"极度贪婪",
2664
+ }
2665
+ label_cn = label_cn_map.get(label_en, label_en)
2666
+ history = [{"date": i.get("timestamp",""), "value": int(i.get("value",0)),
2667
+ "label": i.get("value_classification","")} for i in items]
2668
+ return {
2669
+ "success": True,
2670
+ "value": value,
2671
+ "label": label_cn,
2672
+ "label_en": label_en,
2673
+ "history": history,
2674
+ "signal": "做空" if value >= 75 else "做多" if value <= 25 else "中性",
2675
+ "provider": "alternative.me",
2676
+ }
2677
+ except Exception as e:
2678
+ return {"success": False, "error": str(e)}
2679
+
2680
+
2681
+ def _get_funding_rates(params: dict) -> dict:
2682
+ """
2683
+ 获取永续合约资金费率 (via ccxt)。
2684
+ 支持 binance, okx, bybit 等主流交易所。
2685
+ 高正费率 → 多头过多 → 看空信号;负费率 → 空头过多 → 看多信号。
2686
+ """
2687
+ exchange_id = params.get("exchange", "binance").lower()
2688
+ symbols = params.get("symbols", ["BTC/USDT", "ETH/USDT", "SOL/USDT"])
2689
+ if isinstance(symbols, str):
2690
+ symbols = [s.strip() for s in symbols.replace(",", " ").split()]
2691
+
2692
+ try:
2693
+ import ccxt as _ccxt
2694
+ except ImportError:
2695
+ return {"success": False, "error": "ccxt 未安装: pip install ccxt"}
2696
+
2697
+ # Try exchanges in order; fall back if load_markets fails (network/region issues)
2698
+ _all_exchanges = ["binance", "okx", "bybit"]
2699
+ _exchange_fallback = [exchange_id] + [e for e in _all_exchanges if e != exchange_id]
2700
+
2701
+ for _exid in _exchange_fallback:
2702
+ try:
2703
+ exchange_cls = getattr(_ccxt, _exid, None)
2704
+ if not exchange_cls:
2705
+ continue
2706
+ ex = exchange_cls({"options": {"defaultType": "future"}})
2707
+ try:
2708
+ ex.load_markets()
2709
+ except Exception as _lm_err:
2710
+ _lm_msg = str(_lm_err)
2711
+ if len(_lm_msg) > 100 or "http" in _lm_msg or "GET" in _lm_msg or "POST" in _lm_msg:
2712
+ _lm_msg = f"无法连接 {_exid} 期货市场(网络或区域限制)"
2713
+ if _exid == _exchange_fallback[-1]:
2714
+ return {"success": False, "error": f"{_exid}: {_lm_msg}"}
2715
+ continue
2716
+
2717
+ results = []
2718
+ for sym in symbols:
2719
+ try:
2720
+ fi = ex.fetch_funding_rate(sym)
2721
+ rate = fi.get("fundingRate") or fi.get("funding_rate") or 0
2722
+ next_time = fi.get("fundingDatetime") or fi.get("nextFundingDatetime") or ""
2723
+ annualized = round(float(rate) * 3 * 365 * 100, 2) # 8h intervals
2724
+ results.append({
2725
+ "symbol": sym,
2726
+ "rate": round(float(rate) * 100, 4),
2727
+ "rate_pct": f"{float(rate)*100:.4f}%",
2728
+ "annualized": f"{annualized:.1f}%",
2729
+ "next_funding": str(next_time)[:16],
2730
+ "signal": "空" if float(rate) > 0.0005 else "多" if float(rate) < -0.0001 else "中性",
2731
+ })
2732
+ except Exception:
2733
+ pass
2734
+
2735
+ if not results:
2736
+ if _exid == _exchange_fallback[-1]:
2737
+ return {"success": False, "error": f"已尝试 {', '.join(_exchange_fallback)},均未能获取资金费率数据"}
2738
+ continue
2739
+
2740
+ avg_rate = sum(r["rate"] for r in results) / len(results)
2741
+ return {
2742
+ "success": True,
2743
+ "exchange": _exid,
2744
+ "rates": results,
2745
+ "avg_rate": round(avg_rate, 4),
2746
+ "market_bias": "多头过热(偏空)" if avg_rate > 0.05 else "空头过多(偏多)" if avg_rate < -0.01 else "均衡",
2747
+ "provider": "ccxt",
2748
+ }
2749
+ except Exception as e:
2750
+ _err_msg = str(e)
2751
+ if len(_err_msg) > 100 or "http" in _err_msg or "GET" in _err_msg or "POST" in _err_msg:
2752
+ _err_msg = f"无法连接 {_exid}(网络或区域限制)"
2753
+ if _exid == _exchange_fallback[-1]:
2754
+ return {"success": False, "error": _err_msg}
2755
+
2756
+ return {"success": False, "error": "所有备用交易所均连接失败"}
2757
+
2758
+
2759
+ def _get_funding_rates_compare(params: dict) -> dict:
2760
+ """
2761
+ 并行查询 binance / okx / bybit,返回三所资金费率横向对比。
2762
+ 用于发现跨所套利机会(同一标的费率差 > 0.02% 值得关注)。
2763
+ """
2764
+ symbols = params.get("symbols", ["BTC/USDT", "ETH/USDT", "SOL/USDT"])
2765
+ if isinstance(symbols, str):
2766
+ symbols = [s.strip() for s in symbols.replace(",", " ").split()]
2767
+
2768
+ try:
2769
+ import ccxt as _ccxt # noqa: F401
2770
+ except ImportError:
2771
+ return {"success": False, "error": "ccxt 未安装: pip install ccxt"}
2772
+
2773
+ import concurrent.futures as _fut
2774
+
2775
+ _exchanges = ["binance", "okx", "bybit"]
2776
+
2777
+ def _fetch(exid):
2778
+ return exid, _get_funding_rates({"exchange": exid, "symbols": symbols})
2779
+
2780
+ ex_results: dict = {}
2781
+ with _fut.ThreadPoolExecutor(max_workers=3) as pool:
2782
+ for exid, r in pool.map(_fetch, _exchanges):
2783
+ ex_results[exid] = r
2784
+
2785
+ comparison = []
2786
+ for sym in symbols:
2787
+ row: dict = {"symbol": sym}
2788
+ for exid in _exchanges:
2789
+ r = ex_results.get(exid, {})
2790
+ if r.get("success"):
2791
+ match = next((x for x in r.get("rates", []) if x["symbol"] == sym), None)
2792
+ if match:
2793
+ row[exid] = match
2794
+ if len(row) > 1:
2795
+ comparison.append(row)
2796
+
2797
+ if not comparison:
2798
+ return {"success": False, "error": "三所均无数据,请检查网络或 VPN"}
2799
+
2800
+ # Find max cross-exchange spread per symbol
2801
+ spreads = []
2802
+ for row in comparison:
2803
+ rates = [row[e]["rate"] for e in _exchanges if e in row]
2804
+ if len(rates) >= 2:
2805
+ spreads.append(round(max(rates) - min(rates), 4))
2806
+
2807
+ max_spread = max(spreads) if spreads else 0.0
2808
+ arb_note = (
2809
+ "⚠ 套利机会:最大价差 > 0.02%" if max_spread > 0.02
2810
+ else "价差正常,无明显套利空间"
2811
+ )
2812
+
2813
+ return {
2814
+ "success": True,
2815
+ "comparison": comparison,
2816
+ "exchanges": _exchanges,
2817
+ "max_spread": max_spread,
2818
+ "arb_note": arb_note,
2819
+ "provider": "ccxt_compare",
2820
+ }
2821
+
2822
+
2823
+ # ---------------------------------------------------------------------------
2824
+ # 24. Walk-Forward Backtest (滚动验证)
2825
+ # ---------------------------------------------------------------------------
2826
+
2827
+ def _walk_forward_backtest(params: dict) -> dict:
2828
+ """
2829
+ Walk-Forward 滚动回测:将历史分成 N 个窗口,每窗口 in-sample 优化、
2830
+ out-of-sample 验证,评估策略真实泛化能力。
2831
+ """
2832
+ symbol = params.get("symbol", "AAPL")
2833
+ strategy = params.get("strategy", "sma_crossover")
2834
+ periods = int(params.get("periods", 5)) # number of WF windows
2835
+ train_r = float(params.get("train_ratio", 0.7))
2836
+ period = params.get("period", "5y")
2837
+
2838
+ if not _HAS_YF:
2839
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2840
+
2841
+ try:
2842
+ import numpy as np
2843
+ tkr = yf.Ticker(symbol)
2844
+ df = tkr.history(period=period)
2845
+ if df is None or len(df) < 200:
2846
+ return {"success": False, "error": "历史数据不足(需要至少 200 天)"}
2847
+
2848
+ close = df["Close"].astype(float).values
2849
+ n = len(close)
2850
+ window_size = n // periods
2851
+
2852
+ window_results = []
2853
+ for i in range(periods):
2854
+ start = i * window_size
2855
+ end = start + window_size if i < periods - 1 else n
2856
+ split = start + int((end - start) * train_r)
2857
+
2858
+ train = close[start:split]
2859
+ test = close[split:end]
2860
+
2861
+ if len(test) < 20:
2862
+ continue
2863
+
2864
+ # Simple parameter: SMA crossover with different windows
2865
+ best_sharpe = -np.inf
2866
+ best_fast, best_slow = 10, 30
2867
+
2868
+ if strategy in ("sma_crossover", "ma_crossover"):
2869
+ for fast in (5, 10, 15, 20):
2870
+ for slow in (20, 30, 40, 60):
2871
+ if fast >= slow or len(train) <= slow:
2872
+ continue
2873
+ sig = np.where(
2874
+ np.convolve(train, np.ones(fast)/fast, mode="valid")
2875
+ [-(len(train)-slow+1):] >
2876
+ np.convolve(train, np.ones(slow)/slow, mode="valid"),
2877
+ 1, 0
2878
+ )
2879
+ if len(sig) < 2: continue
2880
+ rets = np.diff(train[-len(sig):]) / train[-len(sig):-1]
2881
+ strat_rets = rets * sig[:-1]
2882
+ sr = (np.mean(strat_rets) / (np.std(strat_rets) + 1e-8)) * np.sqrt(252)
2883
+ if sr > best_sharpe:
2884
+ best_sharpe, best_fast, best_slow = sr, fast, slow
2885
+
2886
+ # Out-of-sample evaluation with best params
2887
+ if len(test) <= best_slow:
2888
+ continue
2889
+ fast_ma = np.convolve(test, np.ones(best_fast)/best_fast, mode="valid")
2890
+ slow_ma = np.convolve(test, np.ones(best_slow)/best_slow, mode="valid")
2891
+ n_sig = min(len(fast_ma), len(slow_ma))
2892
+ signals = np.where(fast_ma[-n_sig:] > slow_ma[-n_sig:], 1, 0)
2893
+ rets_test = np.diff(test[-n_sig:]) / test[-n_sig:-1]
2894
+ strat_rets_oos = rets_test * signals[:-1]
2895
+ bh_rets = rets_test
2896
+
2897
+ oos_total = float(np.prod(1 + strat_rets_oos) - 1)
2898
+ bh_total = float(np.prod(1 + bh_rets) - 1)
2899
+ oos_sharpe = float(np.mean(strat_rets_oos) / (np.std(strat_rets_oos) + 1e-8)) * np.sqrt(252)
2900
+ dd_vals = np.maximum.accumulate(np.cumprod(1 + strat_rets_oos)) - np.cumprod(1 + strat_rets_oos)
2901
+ max_dd = float(np.max(dd_vals) / np.maximum.accumulate(np.cumprod(1 + strat_rets_oos))[-1])
2902
+
2903
+ window_results.append({
2904
+ "window": i + 1,
2905
+ "train_bars": split - start,
2906
+ "test_bars": end - split,
2907
+ "best_fast": best_fast,
2908
+ "best_slow": best_slow,
2909
+ "oos_return": round(oos_total, 4),
2910
+ "bh_return": round(bh_total, 4),
2911
+ "oos_sharpe": round(oos_sharpe, 3),
2912
+ "max_drawdown": round(max_dd, 4),
2913
+ "alpha": round(oos_total - bh_total, 4),
2914
+ })
2915
+
2916
+ if not window_results:
2917
+ return {"success": False, "error": "回测窗口计算失败"}
2918
+
2919
+ avg_oos_ret = sum(w["oos_return"] for w in window_results) / len(window_results)
2920
+ avg_sharpe = sum(w["oos_sharpe"] for w in window_results) / len(window_results)
2921
+ avg_alpha = sum(w["alpha"] for w in window_results) / len(window_results)
2922
+ pct_win = sum(1 for w in window_results if w["oos_return"] > 0) / len(window_results)
2923
+
2924
+ verdict = (
2925
+ "策略泛化能力强" if avg_alpha > 0.02 and avg_sharpe > 0.5
2926
+ else "策略泛化能力中等" if avg_alpha > 0
2927
+ else "策略泛化能力弱(过拟合风险)"
2928
+ )
2929
+
2930
+ return {
2931
+ "success": True,
2932
+ "symbol": symbol,
2933
+ "strategy": strategy,
2934
+ "windows": window_results,
2935
+ "avg_oos_return": round(avg_oos_ret, 4),
2936
+ "avg_sharpe": round(avg_sharpe, 3),
2937
+ "avg_alpha": round(avg_alpha, 4),
2938
+ "win_rate_windows": round(pct_win, 2),
2939
+ "verdict": verdict,
2940
+ "provider": "local",
2941
+ }
2942
+ except Exception as e:
2943
+ return {"success": False, "error": str(e)}
2944
+
2945
+
2946
+ # ---------------------------------------------------------------------------
2947
+ # 25. Peer Comparison (同行对比)
2948
+ # ---------------------------------------------------------------------------
2949
+
2950
+ def _peer_comparison(params: dict) -> dict:
2951
+ """
2952
+ 同行估值与表现对比。
2953
+ 返回 PE/PB/ROE/YTD收益/股息率/市值 横向对比表。
2954
+ """
2955
+ symbol = str(params.get("symbol", "AAPL")).strip().upper()
2956
+ peers = params.get("peers", []) # list of ticker strings
2957
+ if isinstance(peers, str):
2958
+ peers = [p.strip().upper() for p in peers.replace(",", " ").split() if p.strip()]
2959
+
2960
+ # Auto-suggest peers from yfinance sector info if not provided
2961
+ if not peers and _HAS_YF:
2962
+ try:
2963
+ info = yf.Ticker(symbol).info or {}
2964
+ # yfinance doesn't give peers directly; use sector to build manual map
2965
+ _SECTOR_PEERS = {
2966
+ "Technology": ["AAPL","MSFT","GOOGL","META","NVDA","AMZN"],
2967
+ "Financials": ["JPM","BAC","GS","MS","WFC","C"],
2968
+ "Healthcare": ["JNJ","LLY","ABBV","MRK","PFE","UNH"],
2969
+ "Consumer Cyclical": ["AMZN","TSLA","HD","MCD","NKE","SBUX"],
2970
+ "Energy": ["XOM","CVX","COP","SLB","EOG","OXY"],
2971
+ }
2972
+ sector = info.get("sector", "")
2973
+ default_list = _SECTOR_PEERS.get(sector, ["SPY"])
2974
+ peers = [p for p in default_list if p != symbol][:5]
2975
+ except Exception:
2976
+ pass
2977
+
2978
+ if not peers:
2979
+ return {"success": False, "error": "请提供 peers 参数,如: peers=['MSFT','GOOGL','META']"}
2980
+
2981
+ all_symbols = [symbol] + [p for p in peers if p != symbol]
2982
+
2983
+ if not _HAS_YF:
2984
+ return {"success": False, "error": "yfinance not installed: 运行 pip install yfinance 或 /install yfinance"}
2985
+
2986
+ rows = []
2987
+ for sym in all_symbols[:8]:
2988
+ try:
2989
+ info = yf.Ticker(sym).info or {}
2990
+ price = info.get("regularMarketPrice") or info.get("currentPrice") or 0
2991
+ prev = info.get("regularMarketPreviousClose") or price
2992
+ pe = info.get("trailingPE") or info.get("forwardPE")
2993
+ pb = info.get("priceToBook")
2994
+ roe = info.get("returnOnEquity")
2995
+ dy = info.get("dividendYield")
2996
+ mc = info.get("marketCap")
2997
+ ytd = (price / prev - 1) if prev else None
2998
+ rows.append({
2999
+ "symbol": sym,
3000
+ "name": (info.get("shortName") or sym)[:12],
3001
+ "price": round(price, 2),
3002
+ "pe": round(pe, 1) if pe else None,
3003
+ "pb": round(pb, 2) if pb else None,
3004
+ "roe_pct": round(roe * 100, 1) if roe else None,
3005
+ "div_yield": round(dy * 100, 2) if dy else None,
3006
+ "market_cap_b": round(mc / 1e9, 1) if mc else None,
3007
+ "is_target": sym == symbol,
3008
+ })
3009
+ except Exception:
3010
+ pass
3011
+
3012
+ if not rows:
3013
+ return {"success": False, "error": "无法获取对比数据"}
3014
+
3015
+ # Relative rankings
3016
+ pe_vals = [r["pe"] for r in rows if r["pe"] is not None]
3017
+ pb_vals = [r["pb"] for r in rows if r["pb"] is not None]
3018
+ roe_vals = [r["roe_pct"] for r in rows if r["roe_pct"] is not None]
3019
+
3020
+ target_row = next((r for r in rows if r["is_target"]), rows[0])
3021
+ analysis = []
3022
+ if target_row.get("pe") and pe_vals:
3023
+ med_pe = sorted(pe_vals)[len(pe_vals)//2]
3024
+ vs = "高估" if target_row["pe"] > med_pe * 1.2 else "低估" if target_row["pe"] < med_pe * 0.8 else "合理"
3025
+ analysis.append(f"PE {target_row['pe']:.1f}x vs 同行中位数 {med_pe:.1f}x → {vs}")
3026
+ if target_row.get("roe_pct") and roe_vals:
3027
+ avg_roe = sum(roe_vals) / len(roe_vals)
3028
+ vs = "优于同行" if target_row["roe_pct"] > avg_roe else "低于同行"
3029
+ analysis.append(f"ROE {target_row['roe_pct']:.1f}% vs 同行均值 {avg_roe:.1f}% → {vs}")
3030
+
3031
+ return {
3032
+ "success": True,
3033
+ "symbol": symbol,
3034
+ "peers": peers,
3035
+ "table": rows,
3036
+ "analysis": analysis,
3037
+ "provider": "yfinance",
3038
+ }
3039
+
3040
+
3041
+ def _resolve_search_key(env_var: str, provider: str) -> str:
3042
+ """Resolve a search-API key: env var first, then ~/.arthera/providers.json.
3043
+
3044
+ Keys added via `/apikey set <provider> <key>` are stored in providers.json
3045
+ (data section), not exported as env vars — so a web search must check both
3046
+ or it silently falls through to DuckDuckGo despite a configured key.
3047
+ """
3048
+ val = os.getenv(env_var, "")
3049
+ if val:
3050
+ return val
3051
+ try:
3052
+ import json as _json
3053
+ import pathlib as _pl
3054
+ for _loc in ("~/.arthera/providers.json", "~/.aria/providers.json"):
3055
+ p = _pl.Path(_loc).expanduser()
3056
+ if not p.exists():
3057
+ continue
3058
+ raw = _json.loads(p.read_text(encoding="utf-8"))
3059
+ for section in ("data", "llm"):
3060
+ entry = raw.get(section, {}).get(provider.lower(), {})
3061
+ if isinstance(entry, dict) and entry.get("api_key"):
3062
+ return entry["api_key"]
3063
+ if isinstance(entry, str) and entry:
3064
+ return entry
3065
+ except Exception:
3066
+ pass
3067
+ return ""
3068
+
3069
+
3070
+ def _web_search(params: dict) -> dict:
3071
+ """Web search: Brave → Tavily → DuckDuckGo fallback chain."""
3072
+ if not isinstance(params, dict):
3073
+ params = {"query": str(params)}
3074
+ query = (
3075
+ params.get("query")
3076
+ or params.get("q")
3077
+ or params.get("search")
3078
+ or params.get("keyword")
3079
+ or params.get("keywords")
3080
+ or params.get("prompt")
3081
+ or params.get("text")
3082
+ or params.get("symbol")
3083
+ or params.get("ticker")
3084
+ or ""
3085
+ )
3086
+ if isinstance(query, (list, tuple)):
3087
+ query = " ".join(str(x) for x in query if x)
3088
+ elif isinstance(query, dict):
3089
+ query = " ".join(str(v) for v in query.values() if v)
3090
+ query = str(query).strip().strip("'\"`")
3091
+ if not query:
3092
+ # Fallback to general market news query if empty
3093
+ query = "stock market news"
3094
+
3095
+ num = min(int(params.get("num_results", params.get("max_results", 5)) or 5), 10)
3096
+
3097
+ # ── 1. Brave Search API ───────────────────────────────────────────────────
3098
+ brave_key = _resolve_search_key("BRAVE_SEARCH_API_KEY", "brave")
3099
+ if brave_key:
3100
+ try:
3101
+ import urllib.request as _req
3102
+ import urllib.parse as _parse
3103
+ import gzip as _gzip
3104
+ # search_lang must be a valid Brave code. "zh" is INVALID (→ HTTP 422);
3105
+ # use "zh-hans" for Chinese queries and omit it otherwise (auto-detect).
3106
+ _q_params = {"q": query, "count": num, "safesearch": "moderate"}
3107
+ if any("一" <= _c <= "鿿" for _c in query):
3108
+ _q_params["search_lang"] = "zh-hans"
3109
+ url = "https://api.search.brave.com/res/v1/web/search?" + _parse.urlencode(_q_params)
3110
+ req = _req.Request(url, headers={
3111
+ "Accept": "application/json",
3112
+ "Accept-Encoding": "gzip",
3113
+ "X-Subscription-Token": brave_key,
3114
+ })
3115
+ with _req.urlopen(req, timeout=10) as r:
3116
+ raw = r.read()
3117
+ if r.headers.get("Content-Encoding") == "gzip":
3118
+ raw = _gzip.decompress(raw)
3119
+ data = json.loads(raw)
3120
+ results = []
3121
+ for item in data.get("web", {}).get("results", [])[:num]:
3122
+ results.append({
3123
+ "title": item.get("title", ""),
3124
+ "url": item.get("url", ""),
3125
+ "snippet": item.get("description", ""),
3126
+ })
3127
+ if results:
3128
+ return {"success": True, "query": query, "results": results, "provider": "brave"}
3129
+ except Exception as e:
3130
+ # Decompress gzip error bodies so the log is readable, not garbage
3131
+ _detail = str(e)
3132
+ try:
3133
+ import urllib.error as _uerr
3134
+ if isinstance(e, _uerr.HTTPError):
3135
+ _body = e.read()
3136
+ try:
3137
+ import gzip as _gz2
3138
+ if e.headers.get("Content-Encoding") == "gzip":
3139
+ _body = _gz2.decompress(_body)
3140
+ except Exception:
3141
+ pass
3142
+ _detail = f"HTTP {e.code}: {_body.decode('utf-8', 'ignore')[:200]}"
3143
+ except Exception:
3144
+ pass
3145
+ logger.debug("Brave search failed: %s; trying next provider", _detail)
3146
+
3147
+ # ── 2. Tavily API (designed for AI agents, generous free tier) ───────────
3148
+ tavily_key = _resolve_search_key("TAVILY_API_KEY", "tavily")
3149
+ if tavily_key:
3150
+ try:
3151
+ import urllib.request as _req2
3152
+ import urllib.parse as _parse2
3153
+ req2 = _req2.Request(
3154
+ "https://api.tavily.com/search",
3155
+ data=json.dumps({"api_key": tavily_key, "query": query, "max_results": num, "search_depth": "basic"}).encode(),
3156
+ headers={"Content-Type": "application/json"},
3157
+ )
3158
+ with _req2.urlopen(req2, timeout=10) as r2:
3159
+ data2 = json.loads(r2.read())
3160
+ results = [
3161
+ {"title": item.get("title", ""), "url": item.get("url", ""), "snippet": item.get("content", "")[:300]}
3162
+ for item in data2.get("results", [])[:num]
3163
+ ]
3164
+ if results:
3165
+ return {"success": True, "query": query, "results": results, "provider": "tavily"}
3166
+ except Exception as e:
3167
+ logger.debug("Tavily search failed: %s", e)
3168
+
3169
+ # ── 3. Google Programmable Search (Custom Search JSON API) ───────────────
3170
+ # GOOGLE_SEARCH_API_KEY / GOOGLE_SEARCH_ENGINE_ID were documented in
3171
+ # .env.example but never read by any code path, so configuring them did
3172
+ # nothing and the chain fell through to rate-limited DuckDuckGo.
3173
+ google_key = _resolve_search_key("GOOGLE_SEARCH_API_KEY", "google")
3174
+ google_cx = _resolve_search_key("GOOGLE_SEARCH_ENGINE_ID", "google_cx")
3175
+ if google_key and google_cx:
3176
+ try:
3177
+ import urllib.request as _req3
3178
+ import urllib.parse as _parse3
3179
+
3180
+ _g_params = {"key": google_key, "cx": google_cx, "q": query, "num": num}
3181
+ if any("\u4e00" <= _c <= "\u9fff" for _c in query):
3182
+ _g_params["lr"] = "lang_zh-CN"
3183
+ url3 = "https://www.googleapis.com/customsearch/v1?" + _parse3.urlencode(_g_params)
3184
+ with _req3.urlopen(url3, timeout=10) as r3:
3185
+ data3 = json.loads(r3.read())
3186
+ results = [
3187
+ {
3188
+ "title": item.get("title", ""),
3189
+ "url": item.get("link", ""),
3190
+ "snippet": item.get("snippet", ""),
3191
+ }
3192
+ for item in (data3.get("items") or [])[:num]
3193
+ ]
3194
+ if results:
3195
+ return {"success": True, "query": query, "results": results, "provider": "google"}
3196
+ except Exception as e:
3197
+ logger.debug("Google Programmable Search failed: %s; trying next provider", e)
3198
+
3199
+ # ── 4. DuckDuckGo (free, no key, but rate-limited) ────────────────────────
3200
+ try:
3201
+ import warnings as _w
3202
+ with _w.catch_warnings():
3203
+ _w.simplefilter("ignore")
3204
+ try:
3205
+ from ddgs import DDGS
3206
+ except ImportError:
3207
+ from duckduckgo_search import DDGS # type: ignore[no-redef]
3208
+ results = []
3209
+ for item in DDGS().text(query, max_results=num):
3210
+ results.append({
3211
+ "title": item.get("title", ""),
3212
+ "url": item.get("href", ""),
3213
+ "snippet": item.get("body", ""),
3214
+ })
3215
+ if results:
3216
+ return {"success": True, "query": query, "results": results, "provider": "duckduckgo"}
3217
+ return {
3218
+ "success": False,
3219
+ "query": query,
3220
+ "results": [],
3221
+ "error": (
3222
+ "DuckDuckGo returned no results (rate-limited). "
3223
+ "推荐配置: BRAVE_SEARCH_API_KEY (免费2000次/月)、TAVILY_API_KEY (AI专用, 免费1000次/月) "
3224
+ "或 GOOGLE_SEARCH_API_KEY + GOOGLE_SEARCH_ENGINE_ID (免费100次/天)"
3225
+ ),
3226
+ }
3227
+ except ImportError:
3228
+ pass
3229
+ except Exception as e:
3230
+ logger.debug("duckduckgo_search failed: %s", e)
3231
+
3232
+ return {
3233
+ "success": False,
3234
+ "query": query,
3235
+ "results": [],
3236
+ "error": (
3237
+ "无可用搜索服务。推荐配置:\n"
3238
+ " BRAVE_SEARCH_API_KEY — https://brave.com/search/api/ (免费2000次/月)\n"
3239
+ " TAVILY_API_KEY — https://tavily.com (AI专用, 免费1000次/月)\n"
3240
+ " GOOGLE_SEARCH_API_KEY + GOOGLE_SEARCH_ENGINE_ID\n"
3241
+ " — https://developers.google.com/custom-search (免费100次/天)\n"
3242
+ " 或安装: pip install duckduckgo-search"
3243
+ ),
3244
+ }
3245
+
3246
+
3247
+ def _run_portfolio_backtest(params: dict) -> dict:
3248
+ """Event-driven, multi-asset backtest via backtest_engine.
3249
+
3250
+ Unlike backtest_strategy (single-symbol templates), this runs a Strategy over
3251
+ a portfolio of symbols with next-open fills (no look-ahead), commission +
3252
+ slippage, and an equal-weight buy-hold benchmark. Returns a compact result.
3253
+ """
3254
+ raw = params.get("symbols") or params.get("symbol") or ""
3255
+ if isinstance(raw, str):
3256
+ symbols = [s.strip().upper() for s in raw.replace(",", " ").split() if s.strip()]
3257
+ else:
3258
+ symbols = [str(s).strip().upper() for s in (raw or []) if str(s).strip()]
3259
+ if not symbols:
3260
+ return {"success": False, "error": "symbols is required"}
3261
+
3262
+ try:
3263
+ from aria_code.backtest_engine import BacktestEngine, get_strategy, load_bars
3264
+ except Exception as exc:
3265
+ return {"success": False, "error": f"backtest engine unavailable: {exc}"}
3266
+
3267
+ strategy_name = str(params.get("strategy", "sma_cross") or "sma_cross")
3268
+ skw: dict = {}
3269
+ for k in ("fast", "slow"):
3270
+ if params.get(k) is not None:
3271
+ try:
3272
+ skw[k] = int(params[k])
3273
+ except (TypeError, ValueError):
3274
+ pass
3275
+ if params.get("weight") is not None:
3276
+ try:
3277
+ skw["weight"] = float(params["weight"])
3278
+ except (TypeError, ValueError):
3279
+ pass
3280
+ try:
3281
+ strategy = get_strategy(strategy_name, **skw)
3282
+ except ValueError as e:
3283
+ return {"success": False, "error": str(e)}
3284
+
3285
+ try:
3286
+ days = max(30, min(int(params.get("days", 365) or 365), 2000))
3287
+ except (TypeError, ValueError):
3288
+ days = 365
3289
+
3290
+ data, missing = {}, []
3291
+ for s in symbols:
3292
+ try:
3293
+ bars = load_bars(s, days=days)
3294
+ except Exception:
3295
+ bars = []
3296
+ (data.__setitem__(s, bars) if bars else missing.append(s))
3297
+ if not data:
3298
+ return {"success": False, "error": "无法获取历史数据,请稍后重试",
3299
+ "symbols": symbols, "missing": missing}
3300
+
3301
+ def _f(key, default):
3302
+ try:
3303
+ return float(params.get(key, default) or default)
3304
+ except (TypeError, ValueError):
3305
+ return default
3306
+
3307
+ eng = BacktestEngine(starting_cash=_f("starting_cash", 100000.0),
3308
+ commission=_f("commission", 0.0005),
3309
+ slippage=_f("slippage", 0.0005))
3310
+ res = eng.run(data, strategy)
3311
+
3312
+ ec = res.equity_curve
3313
+ step = max(1, len(ec) // 20)
3314
+ sample = ec[::step]
3315
+ if ec and sample[-1] is not ec[-1]:
3316
+ sample.append(ec[-1])
3317
+ return {
3318
+ "success": True,
3319
+ "strategy": res.strategy,
3320
+ "symbols": res.symbols,
3321
+ "missing": missing,
3322
+ "starting_cash": res.starting_cash,
3323
+ "metrics": res.metrics,
3324
+ "benchmark": res.benchmark,
3325
+ "alpha_vs_buyhold": round(res.metrics["total_return"] - res.benchmark.get("total_return", 0.0), 6),
3326
+ "n_points": len(ec),
3327
+ "equity_sample": sample,
3328
+ "recent_trades": res.trades[-10:],
3329
+ }
3330
+
3331
+
3332
+ # ---------------------------------------------------------------------------
3333
+ # Tool registry (must be after all function definitions)
3334
+ # ---------------------------------------------------------------------------
3335
+
3336
+ LOCAL_FINANCE_TOOL_REGISTRY: Dict[str, Tuple] = {
3337
+ # ── Market data (cloud → local fallback) ─────────────────────────────
3338
+ "get_market_data": (_get_market_data, "Stock/ETF quotes and OHLCV history (A股/US/global, cloud-backed)"),
3339
+ "get_crypto_data": (_get_crypto_data, "Cryptocurrency OHLCV and ticker data via ccxt/yfinance"),
3340
+ "get_forex_data": (_get_forex_data, "Foreign exchange rates (yfinance)"),
3341
+ "get_commodities_data": (_get_commodities_data, "Commodity futures prices: gold, oil, copper, wheat, etc. (yfinance)"),
3342
+ "get_futures_data": (_get_futures_data, "Equity index futures: S&P, NASDAQ, VIX, Nikkei, etc. (yfinance)"),
3343
+ # ── Factor & signal (cloud → local fallback) ─────────────────────────
3344
+ "calculate_factors": (_calculate_factors, "Technical factors: RSI, MACD, MA gaps, volatility, momentum, trend score (cloud-enhanced)"),
3345
+ "get_ai_signal": (_get_ai_signal, "AI trading signal: BUY/SELL/HOLD with confidence and reasoning (Alibaba Cloud DeepSeek)"),
3346
+ "get_market_insights": (_get_market_insights, "AI narrative market insights for a basket of symbols (Alibaba Cloud)"),
3347
+ "get_predictions": (_get_predictions, "ML-powered 5/10-day return predictions (Alibaba Cloud LightGBM/XGBoost)"),
3348
+ # ── Backtest (cloud ML → local pandas fallback) ───────────────────────
3349
+ "backtest_strategy": (_backtest_strategy, "Run a local pandas backtest: sma_cross | rsi_mean_revert | momentum | buy_hold"),
3350
+ "cloud_backtest": (_cloud_backtest, "Full ML-powered backtest via Alibaba Cloud QuantEngine (rebalance freq, dynamic position)"),
3351
+ # ── Risk & portfolio ──────────────────────────────────────────────────
3352
+ "get_risk_metrics": (_get_risk_metrics, "VaR, CVaR, max drawdown, Sharpe, Calmar, skew, kurtosis"),
3353
+ "optimize_positions": (_optimize_positions, "Portfolio optimisation: max_sharpe | min_var | equal_weight"),
3354
+ # ── A股 data services ─────────────────────────────────────────────────
3355
+ "get_sector_performance": (_get_sector_performance, "Sector performance ranking (A股 industry / US SPDR ETFs)"),
3356
+ "get_northbound_flow": (_get_northbound_flow, "北向资金 (沪深港通) net buy flow via akshare"),
3357
+ "screen_ashare": (_screen_ashare, "A股选股筛选: PE, ROE, market cap, momentum"),
3358
+ "get_limit_up_pool": (_get_limit_up_pool, "A股涨停板池 (today's limit-up stocks via akshare)"),
3359
+ "get_market_indices": (_get_market_indices, "Global market indices: US, CN, EU, crypto, commodities"),
3360
+ # ── News & macro ──────────────────────────────────────────────────────
3361
+ "analyze_news": (_analyze_news, "News sentiment analysis (A股 via akshare)"),
3362
+ "get_bonds_data": (_get_bonds_data, "US Treasury yield curve (yfinance)"),
3363
+ # ── Quality scores ────────────────────────────────────────────────────
3364
+ "piotroski_fscore": (_piotroski_fscore, "Piotroski F-Score (0-9): 财务质量评分,≥7 高质量做多,≤3 低质量做空"),
3365
+ "altman_zscore": (_altman_zscore, "Altman Z''-Score: 企业破产风险预测,>2.6安全,<1.1高风险"),
3366
+ # ── Options ───────────────────────────────────────────────────────────
3367
+ "get_options_chain": (_get_options_chain, "期权链: 获取股票的 calls/puts,含行权价、隐含波动率、未平仓量"),
3368
+ # ── Technical indicators ──────────────────────────────────────────────
3369
+ "calculate_ichimoku": (_calculate_ichimoku, "一目均衡表 Ichimoku Cloud: 转换线/基准线/先行带/迟行线,含信号判断"),
3370
+ # ── Crypto ────────────────────────────────────────────────────────────
3371
+ "get_fear_greed_index": (_get_fear_greed_index, "加密恐惧贪婪指数 (0-100),>75极度贪婪/做空信号,<25极度恐惧/做多信号"),
3372
+ "get_funding_rates": (_get_funding_rates, "永续合约资金费率 (ccxt): 高正费率=多头过热,负费率=空头过多"),
3373
+ "get_funding_rates_compare": (_get_funding_rates_compare, "三所费率横向对比 (binance/okx/bybit): 并行查询,发现跨所套利机会"),
3374
+ # ── Portfolio / backtesting ───────────────────────────────────────────
3375
+ "walk_forward_backtest": (_walk_forward_backtest, "Walk-Forward 滚动回测:N个窗口验证策略泛化能力,避免过拟合"),
3376
+ "run_portfolio_backtest": (_run_portfolio_backtest, "事件驱动多资产回测:next-open 成交(无未来函数)+成本+买入持有基准,同一策略可驱动实盘"),
3377
+ "peer_comparison": (_peer_comparison, "同行对比: PE/PB/ROE/市值/股息率横向比较,自动识别同行业股票"),
3378
+ # ── Web search ────────────────────────────────────────────────────────
3379
+ "web_search": (_web_search, "Web search via Brave Search API or DuckDuckGo fallback; set BRAVE_SEARCH_API_KEY for higher quota"),
3380
+ }
3381
+
3382
+ # ---------------------------------------------------------------------------
3383
+ # Registration helper
3384
+ # ---------------------------------------------------------------------------
3385
+
3386
+ def register_local_finance_tools(
3387
+ tool_registry: Dict,
3388
+ schema_registry: List,
3389
+ ) -> int:
3390
+ """
3391
+ Add local finance tools to the CLI's LOCAL_TOOLS and LOCAL_TOOL_SCHEMAS.
3392
+
3393
+ Only registers tools whose names are NOT already present (never overwrites
3394
+ existing tools, so remote Aria tools take precedence when backend is up).
3395
+
3396
+ Returns number of tools newly registered.
3397
+ """
3398
+ added = 0
3399
+ for name, (handler, description) in LOCAL_FINANCE_TOOL_REGISTRY.items():
3400
+ if name not in tool_registry:
3401
+ tool_registry[name] = (
3402
+ lambda p, h=handler: _safe(h, p),
3403
+ description,
3404
+ )
3405
+ added += 1
3406
+
3407
+ existing_schema_names = {
3408
+ s.get("function", {}).get("name") for s in schema_registry
3409
+ }
3410
+ for schema in LOCAL_FINANCE_TOOL_SCHEMAS:
3411
+ sname = schema.get("function", {}).get("name", "")
3412
+ if sname and sname not in existing_schema_names:
3413
+ schema_registry.append(schema)
3414
+
3415
+ return added
3416
+
3417
+ return added