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