aria-code 0.167.0__py3-none-win_amd64.whl

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