aria-code 0.52.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (478) hide show
  1. aria_code/__init__.py +42 -0
  2. aria_code/_version.py +20 -0
  3. aria_code/adk_apps/__init__.py +1 -0
  4. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  5. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  6. aria_code/adk_apps/aria_research/__init__.py +1 -0
  7. aria_code/adk_apps/aria_research/agent.py +31 -0
  8. aria_code/agents/__init__.py +34 -0
  9. aria_code/agents/base.py +318 -0
  10. aria_code/agents/code_review.py +200 -0
  11. aria_code/agents/deep/__init__.py +37 -0
  12. aria_code/agents/deep/calibration_loop.py +144 -0
  13. aria_code/agents/deep/critic.py +125 -0
  14. aria_code/agents/deep/deepen.py +193 -0
  15. aria_code/agents/deep/models.py +149 -0
  16. aria_code/agents/deep/pipeline.py +164 -0
  17. aria_code/agents/deep/quant_fusion.py +192 -0
  18. aria_code/agents/deep/themes.py +95 -0
  19. aria_code/agents/deep/tiers.py +106 -0
  20. aria_code/agents/engineering/__init__.py +14 -0
  21. aria_code/agents/engineering/coder.py +222 -0
  22. aria_code/agents/engineering/tester.py +145 -0
  23. aria_code/agents/financial/__init__.py +21 -0
  24. aria_code/agents/financial/cashflow_burn.py +119 -0
  25. aria_code/agents/financial/catalyst.py +279 -0
  26. aria_code/agents/financial/corporate_finance.py +175 -0
  27. aria_code/agents/financial/debate.py +224 -0
  28. aria_code/agents/financial/earnings.py +303 -0
  29. aria_code/agents/financial/fundamental.py +191 -0
  30. aria_code/agents/financial/macro.py +99 -0
  31. aria_code/agents/financial/news.py +207 -0
  32. aria_code/agents/financial/risk.py +165 -0
  33. aria_code/agents/financial/sector.py +279 -0
  34. aria_code/agents/financial/strategist.py +121 -0
  35. aria_code/agents/financial/stripe_revenue.py +127 -0
  36. aria_code/agents/financial/synthesis.py +274 -0
  37. aria_code/agents/financial/technical.py +258 -0
  38. aria_code/agents/orchestrator.py +44 -0
  39. aria_code/agents/portfolio_agent.py +363 -0
  40. aria_code/agents/realty/__init__.py +62 -0
  41. aria_code/agents/realty/asset_diagnosis.py +152 -0
  42. aria_code/agents/realty/business_match.py +166 -0
  43. aria_code/agents/realty/cashflow_verify.py +209 -0
  44. aria_code/agents/realty/contract_rules.py +210 -0
  45. aria_code/agents/realty/energy_anomaly.py +189 -0
  46. aria_code/agents/realty/exit_settlement.py +208 -0
  47. aria_code/agents/realty/fulfillment_risk.py +206 -0
  48. aria_code/agents/realty/ops_optimize.py +160 -0
  49. aria_code/agents/realty/revenue_share.py +215 -0
  50. aria_code/agents/registry.py +158 -0
  51. aria_code/agents/signal_scheme.py +111 -0
  52. aria_code/agents/sports/__init__.py +0 -0
  53. aria_code/agents/sports/football_agent.py +197 -0
  54. aria_code/agents/supervisor.py +113 -0
  55. aria_code/agents/team.py +492 -0
  56. aria_code/agents/warehouse/__init__.py +25 -0
  57. aria_code/agents/warehouse/contracts.py +26 -0
  58. aria_code/agents/warehouse/dashboard.py +155 -0
  59. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  60. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  61. aria_code/agents/warehouse/inventory_health.py +24 -0
  62. aria_code/agents/warehouse/local_run.py +71 -0
  63. aria_code/agents/warehouse/local_snapshot.py +132 -0
  64. aria_code/agents/warehouse/logistics_cost.py +84 -0
  65. aria_code/agents/warehouse/logistics_sync.py +21 -0
  66. aria_code/agents/warehouse/workflow.py +31 -0
  67. aria_code/aliyun_data_client.py +4 -0
  68. aria_code/apps/README.md +12 -0
  69. aria_code/apps/__init__.py +2 -0
  70. aria_code/apps/channels/README.md +15 -0
  71. aria_code/apps/channels/__init__.py +17 -0
  72. aria_code/apps/channels/intake.py +92 -0
  73. aria_code/apps/channels/registry.py +95 -0
  74. aria_code/apps/channels/tradingview.py +94 -0
  75. aria_code/apps/cli/README.md +13 -0
  76. aria_code/apps/cli/__init__.py +2 -0
  77. aria_code/apps/cli/_optional.py +120 -0
  78. aria_code/apps/cli/bootstrap.py +145 -0
  79. aria_code/apps/cli/broker_render.py +206 -0
  80. aria_code/apps/cli/codegen_paths.py +29 -0
  81. aria_code/apps/cli/commands/__init__.py +16 -0
  82. aria_code/apps/cli/commands/_business_render.py +78 -0
  83. aria_code/apps/cli/commands/_ui.py +71 -0
  84. aria_code/apps/cli/commands/analysis_cmds.py +369 -0
  85. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  86. aria_code/apps/cli/commands/auth_cmds.py +239 -0
  87. aria_code/apps/cli/commands/backtest_cmds.py +2543 -0
  88. aria_code/apps/cli/commands/broker_cmds.py +1234 -0
  89. aria_code/apps/cli/commands/business_workflow_cmds.py +319 -0
  90. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  91. aria_code/apps/cli/commands/catalog.py +87 -0
  92. aria_code/apps/cli/commands/core_cmds.py +2520 -0
  93. aria_code/apps/cli/commands/data_cmds.py +457 -0
  94. aria_code/apps/cli/commands/diagnostic_cmds.py +230 -0
  95. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  96. aria_code/apps/cli/commands/file_cmds.py +312 -0
  97. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  98. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  99. aria_code/apps/cli/commands/market.py +434 -0
  100. aria_code/apps/cli/commands/market_cmds.py +1381 -0
  101. aria_code/apps/cli/commands/market_context.py +425 -0
  102. aria_code/apps/cli/commands/market_render.py +7 -0
  103. aria_code/apps/cli/commands/model_cmds.py +2153 -0
  104. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  105. aria_code/apps/cli/commands/orchestrator_cmds.py +179 -0
  106. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  107. aria_code/apps/cli/commands/portfolio_cmds.py +1278 -0
  108. aria_code/apps/cli/commands/report.py +437 -0
  109. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  110. aria_code/apps/cli/commands/session_cmds.py +236 -0
  111. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  112. aria_code/apps/cli/commands/team.py +669 -0
  113. aria_code/apps/cli/commands/team_render.py +8 -0
  114. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  115. aria_code/apps/cli/commands/warehouse_cmds.py +79 -0
  116. aria_code/apps/cli/commands/workflow_cmds.py +480 -0
  117. aria_code/apps/cli/commands/workspace_cmds.py +1494 -0
  118. aria_code/apps/cli/config_paths.py +73 -0
  119. aria_code/apps/cli/config_store.py +78 -0
  120. aria_code/apps/cli/constants.py +88 -0
  121. aria_code/apps/cli/context.py +34 -0
  122. aria_code/apps/cli/deterministic.py +191 -0
  123. aria_code/apps/cli/direct.py +48 -0
  124. aria_code/apps/cli/finance_service_catalog.py +111 -0
  125. aria_code/apps/cli/football_reports.py +231 -0
  126. aria_code/apps/cli/github_app_auth.py +135 -0
  127. aria_code/apps/cli/google_login.py +196 -0
  128. aria_code/apps/cli/handlers/__init__.py +11 -0
  129. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  130. aria_code/apps/cli/handlers/chart_handlers.py +1309 -0
  131. aria_code/apps/cli/handlers/market_handlers.py +2513 -0
  132. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  133. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  134. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  135. aria_code/apps/cli/helpers.py +302 -0
  136. aria_code/apps/cli/hooks.py +180 -0
  137. aria_code/apps/cli/i18n.py +297 -0
  138. aria_code/apps/cli/intent.py +136 -0
  139. aria_code/apps/cli/intent_router.py +276 -0
  140. aria_code/apps/cli/intent_signals.py +344 -0
  141. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  142. aria_code/apps/cli/local_tool_schemas.py +536 -0
  143. aria_code/apps/cli/main.py +29 -0
  144. aria_code/apps/cli/market_metadata.py +135 -0
  145. aria_code/apps/cli/market_universe.py +487 -0
  146. aria_code/apps/cli/message_processing.py +310 -0
  147. aria_code/apps/cli/model_catalog.py +620 -0
  148. aria_code/apps/cli/pdf_report.py +977 -0
  149. aria_code/apps/cli/plan_mode.py +139 -0
  150. aria_code/apps/cli/plotly_html.py +15 -0
  151. aria_code/apps/cli/prediction_feedback.py +202 -0
  152. aria_code/apps/cli/preflight.py +502 -0
  153. aria_code/apps/cli/project_aria.py +60 -0
  154. aria_code/apps/cli/prompt_assembly.py +59 -0
  155. aria_code/apps/cli/prompts/__init__.py +0 -0
  156. aria_code/apps/cli/prompts/coding.py +53 -0
  157. aria_code/apps/cli/prompts/select.py +142 -0
  158. aria_code/apps/cli/prompts/system_prompts.py +552 -0
  159. aria_code/apps/cli/prompts/ui.py +436 -0
  160. aria_code/apps/cli/provider_endpoints.py +237 -0
  161. aria_code/apps/cli/provider_keys.py +73 -0
  162. aria_code/apps/cli/providers/__init__.py +1 -0
  163. aria_code/apps/cli/providers/base.py +521 -0
  164. aria_code/apps/cli/providers/chat_routing.py +128 -0
  165. aria_code/apps/cli/providers/collaboration.py +98 -0
  166. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  167. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  168. aria_code/apps/cli/providers/llm/ollama_stream.py +1594 -0
  169. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  170. aria_code/apps/cli/providers/llm/sse_stream.py +257 -0
  171. aria_code/apps/cli/providers/runtime_bridge.py +353 -0
  172. aria_code/apps/cli/providers/vertexai_stream.py +316 -0
  173. aria_code/apps/cli/runtime_consumer.py +657 -0
  174. aria_code/apps/cli/sandbox.py +32 -0
  175. aria_code/apps/cli/session_export.py +120 -0
  176. aria_code/apps/cli/session_jsonl.py +208 -0
  177. aria_code/apps/cli/session_store.py +112 -0
  178. aria_code/apps/cli/skills_catalog.py +273 -0
  179. aria_code/apps/cli/todo_tracker.py +190 -0
  180. aria_code/apps/cli/tool_executor.py +823 -0
  181. aria_code/apps/cli/tool_registry.py +34 -0
  182. aria_code/apps/cli/tool_scope.py +106 -0
  183. aria_code/apps/cli/tools/__init__.py +40 -0
  184. aria_code/apps/cli/tools/context.py +46 -0
  185. aria_code/apps/cli/tools/file_tools.py +180 -0
  186. aria_code/apps/cli/tools/market_tools.py +620 -0
  187. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  188. aria_code/apps/cli/tools/system_tools.py +726 -0
  189. aria_code/apps/cli/tools/write_tools.py +845 -0
  190. aria_code/apps/cli/tradingview_bridge.py +529 -0
  191. aria_code/apps/cli/turn_planning.py +137 -0
  192. aria_code/apps/cli/update_check.py +153 -0
  193. aria_code/apps/cli/utils/__init__.py +0 -0
  194. aria_code/apps/cli/utils/market_detect.py +1597 -0
  195. aria_code/apps/daemon/README.md +14 -0
  196. aria_code/apps/vscode/README.md +115 -0
  197. aria_code/apps/vscode/package.json +70 -0
  198. aria_code/aria_cli.py +6743 -0
  199. aria_code/aria_daemon.py +1398 -0
  200. aria_code/aria_feishu_bot.py +1423 -0
  201. aria_code/aria_mcp_server.py +7 -0
  202. aria_code/aria_relay_client.py +4 -0
  203. aria_code/aria_relay_server.py +490 -0
  204. aria_code/aria_telegram_bot.py +202 -0
  205. aria_code/ariarc.py +385 -0
  206. aria_code/artifacts.py +581 -0
  207. aria_code/backtest_engine.py +4 -0
  208. aria_code/backtest_report.py +4 -0
  209. aria_code/brokers/__init__.py +78 -0
  210. aria_code/brokers/automation.py +193 -0
  211. aria_code/brokers/base.py +231 -0
  212. aria_code/brokers/capabilities.py +264 -0
  213. aria_code/brokers/cn/__init__.py +10 -0
  214. aria_code/brokers/cn/easytrader_broker.py +193 -0
  215. aria_code/brokers/cn/futu_broker.py +194 -0
  216. aria_code/brokers/cn/longbridge_broker.py +190 -0
  217. aria_code/brokers/cn/tiger_broker.py +196 -0
  218. aria_code/brokers/cn/xtquant_broker.py +225 -0
  219. aria_code/brokers/config.py +414 -0
  220. aria_code/brokers/intl/__init__.py +5 -0
  221. aria_code/brokers/intl/alpaca_broker.py +183 -0
  222. aria_code/brokers/intl/ibkr_broker.py +215 -0
  223. aria_code/brokers/intl/trading212_broker.py +183 -0
  224. aria_code/brokers/intl/webull_broker.py +156 -0
  225. aria_code/brokers/paper_broker.py +266 -0
  226. aria_code/brokers/planning.py +296 -0
  227. aria_code/brokers/registry.py +213 -0
  228. aria_code/brokers/trading.py +267 -0
  229. aria_code/canva_client.py +4 -0
  230. aria_code/change_store.py +127 -0
  231. aria_code/clients/__init__.py +0 -0
  232. aria_code/clients/aliyun_data_client.py +660 -0
  233. aria_code/clients/aria_relay_client.py +182 -0
  234. aria_code/clients/canva_client.py +284 -0
  235. aria_code/clients/figma_client.py +100 -0
  236. aria_code/clients/football_data_client.py +1671 -0
  237. aria_code/clients/kling_video_client.py +182 -0
  238. aria_code/clients/market_data_client.py +1976 -0
  239. aria_code/clients/openai_image_client.py +242 -0
  240. aria_code/clients/runway_video_client.py +157 -0
  241. aria_code/clients/warehouse_erp_client.py +105 -0
  242. aria_code/cloud_memory.py +181 -0
  243. aria_code/command_safety.py +19 -0
  244. aria_code/computer_use_tools.py +4 -0
  245. aria_code/dashboard_generator.py +579 -0
  246. aria_code/data_analysis_tools.py +4 -0
  247. aria_code/data_cleaner.py +483 -0
  248. aria_code/data_service.py +513 -0
  249. aria_code/datasources/__init__.py +23 -0
  250. aria_code/datasources/base.py +166 -0
  251. aria_code/datasources/router.py +253 -0
  252. aria_code/datasources/sources/__init__.py +15 -0
  253. aria_code/datasources/sources/akshare_source.py +269 -0
  254. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  255. aria_code/datasources/sources/edgar_source.py +219 -0
  256. aria_code/datasources/sources/finnhub_source.py +198 -0
  257. aria_code/datasources/sources/fred_source.py +223 -0
  258. aria_code/datasources/sources/tushare_source.py +143 -0
  259. aria_code/datasources/sources/web_scraper_source.py +278 -0
  260. aria_code/datasources/sources/world_bank_source.py +206 -0
  261. aria_code/datasources/sources/yfinance_source.py +152 -0
  262. aria_code/demo_player.py +207 -0
  263. aria_code/doctor.py +813 -0
  264. aria_code/domain/__init__.py +0 -0
  265. aria_code/domain/backtest_engine.py +354 -0
  266. aria_code/domain/backtest_report.py +472 -0
  267. aria_code/domain/finance_formulas.py +389 -0
  268. aria_code/domain/portfolio_ledger.py +307 -0
  269. aria_code/domain/strategy_vault.py +421 -0
  270. aria_code/evals/__init__.py +37 -0
  271. aria_code/evals/harness.py +598 -0
  272. aria_code/evals/runner.py +238 -0
  273. aria_code/external_agent_runner.py +115 -0
  274. aria_code/figma_client.py +4 -0
  275. aria_code/file_analysis_tools.py +4 -0
  276. aria_code/finance_formulas.py +4 -0
  277. aria_code/football_data_client.py +4 -0
  278. aria_code/image_gen_tools.py +4 -0
  279. aria_code/intent_classifier.py +386 -0
  280. aria_code/kling_video_client.py +4 -0
  281. aria_code/licensing.py +151 -0
  282. aria_code/local_finance_tools.py +4 -0
  283. aria_code/local_image_provider.py +4 -0
  284. aria_code/local_llm_provider.py +4 -0
  285. aria_code/macro_tools.py +4 -0
  286. aria_code/markdown_pdf.py +289 -0
  287. aria_code/market_data_client.py +4 -0
  288. aria_code/mcp_client.py +639 -0
  289. aria_code/memory_manager.py +246 -0
  290. aria_code/model_capability.py +486 -0
  291. aria_code/notification_tools.py +4 -0
  292. aria_code/openai_image_client.py +4 -0
  293. aria_code/packages/__init__.py +23 -0
  294. aria_code/packages/adk_bridge/__init__.py +11 -0
  295. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  296. aria_code/packages/adk_bridge/market_tools.py +125 -0
  297. aria_code/packages/aria_agents/__init__.py +5 -0
  298. aria_code/packages/aria_agents/manifest.py +78 -0
  299. aria_code/packages/aria_core/__init__.py +34 -0
  300. aria_code/packages/aria_core/architecture.py +269 -0
  301. aria_code/packages/aria_core/export.py +147 -0
  302. aria_code/packages/aria_core/manifest.py +65 -0
  303. aria_code/packages/aria_core/paths.py +57 -0
  304. aria_code/packages/aria_core/secure_file.py +68 -0
  305. aria_code/packages/aria_infra/__init__.py +15 -0
  306. aria_code/packages/aria_infra/arthera.py +52 -0
  307. aria_code/packages/aria_infra/doctor.py +246 -0
  308. aria_code/packages/aria_infra/product.py +40 -0
  309. aria_code/packages/aria_mcp/__init__.py +25 -0
  310. aria_code/packages/aria_mcp/bridge.py +201 -0
  311. aria_code/packages/aria_mcp/circuit.py +82 -0
  312. aria_code/packages/aria_mcp/config.py +111 -0
  313. aria_code/packages/aria_mcp/installer.py +95 -0
  314. aria_code/packages/aria_mcp/server.py +1550 -0
  315. aria_code/packages/aria_mcp/tools.py +81 -0
  316. aria_code/packages/aria_sdk/__init__.py +19 -0
  317. aria_code/packages/aria_sdk/client.py +396 -0
  318. aria_code/packages/aria_sdk/providers.py +70 -0
  319. aria_code/packages/aria_sdk/streaming.py +79 -0
  320. aria_code/packages/aria_sdk/types.py +86 -0
  321. aria_code/packages/aria_services/__init__.py +92 -0
  322. aria_code/packages/aria_services/cache.py +60 -0
  323. aria_code/packages/aria_services/context.py +356 -0
  324. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  325. aria_code/packages/aria_services/data.py +11 -0
  326. aria_code/packages/aria_services/provider_health.py +268 -0
  327. aria_code/packages/aria_services/references.py +247 -0
  328. aria_code/packages/aria_services/registry.py +249 -0
  329. aria_code/packages/aria_services/research_protocol.py +57 -0
  330. aria_code/packages/aria_services/research_quality.py +186 -0
  331. aria_code/packages/aria_services/settings.py +157 -0
  332. aria_code/packages/aria_services/usage.py +151 -0
  333. aria_code/packages/aria_skills/__init__.py +46 -0
  334. aria_code/packages/aria_skills/catalog.py +149 -0
  335. aria_code/packages/aria_skills/loader.py +455 -0
  336. aria_code/packages/aria_skills/registry.py +84 -0
  337. aria_code/packages/aria_tools/__init__.py +5 -0
  338. aria_code/packages/aria_tools/financial/__init__.py +8 -0
  339. aria_code/packages/aria_tools/financial/_paths.py +130 -0
  340. aria_code/packages/aria_tools/financial/compliance.py +31 -0
  341. aria_code/packages/aria_tools/financial/factors.py +33 -0
  342. aria_code/packages/aria_tools/financial/risk_tools.py +33 -0
  343. aria_code/packages/aria_tools/financial/strategy.py +31 -0
  344. aria_code/packages/aria_tools/registry.py +128 -0
  345. aria_code/packages/quant_engine/__init__.py +34 -0
  346. aria_code/packages/quant_engine/_namespace.py +62 -0
  347. aria_code/packages/quant_engine/backtest/__init__.py +14 -0
  348. aria_code/packages/quant_engine/backtest/core.py +53 -0
  349. aria_code/packages/quant_engine/backtest/engine.py +96 -0
  350. aria_code/packages/quant_engine/portfolio/__init__.py +13 -0
  351. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  352. aria_code/packages/quant_engine/sports/__init__.py +80 -0
  353. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  354. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  355. aria_code/packages/quant_engine/sports/elo.py +300 -0
  356. aria_code/packages/quant_engine/sports/form.py +188 -0
  357. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  358. aria_code/packages/quant_engine/sports/ml_model.py +377 -0
  359. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  360. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  361. aria_code/packages/quant_engine/stochastic/__init__.py +35 -0
  362. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  363. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  364. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  365. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  366. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  367. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  368. aria_code/packs/__init__.py +85 -0
  369. aria_code/packs/base.py +168 -0
  370. aria_code/packs/finance/__init__.py +250 -0
  371. aria_code/packs/logistics/__init__.py +169 -0
  372. aria_code/packs/payments/__init__.py +139 -0
  373. aria_code/packs/realty/__init__.py +190 -0
  374. aria_code/packs/registry.py +202 -0
  375. aria_code/packs/rules.py +90 -0
  376. aria_code/plan_utils.py +194 -0
  377. aria_code/plugin_loader.py +328 -0
  378. aria_code/portfolio_ledger.py +4 -0
  379. aria_code/preview_server.py +449 -0
  380. aria_code/privacy/__init__.py +5 -0
  381. aria_code/privacy/feedback.py +123 -0
  382. aria_code/project_review.py +278 -0
  383. aria_code/project_review_api.py +235 -0
  384. aria_code/project_review_server.py +18 -0
  385. aria_code/project_tools.py +4 -0
  386. aria_code/providers/__init__.py +30 -0
  387. aria_code/providers/llm/__init__.py +19 -0
  388. aria_code/providers/llm/anthropic.py +184 -0
  389. aria_code/providers/llm/autoconfig.py +213 -0
  390. aria_code/providers/llm/base.py +139 -0
  391. aria_code/providers/llm/custom.py +158 -0
  392. aria_code/providers/llm/ollama.py +129 -0
  393. aria_code/providers/llm/openai_compat.py +313 -0
  394. aria_code/providers/llm/registry.py +444 -0
  395. aria_code/providers/local_image_provider.py +202 -0
  396. aria_code/providers/local_llm_provider.py +589 -0
  397. aria_code/realty_data_tools.py +4 -0
  398. aria_code/report_exporters.py +150 -0
  399. aria_code/report_generator.py +1764 -0
  400. aria_code/runtime/__init__.py +169 -0
  401. aria_code/runtime/acceptance.py +481 -0
  402. aria_code/runtime/agent_loop.py +1520 -0
  403. aria_code/runtime/approval.py +57 -0
  404. aria_code/runtime/budget.py +232 -0
  405. aria_code/runtime/checkpoints.py +432 -0
  406. aria_code/runtime/crash_report.py +174 -0
  407. aria_code/runtime/events.py +113 -0
  408. aria_code/runtime/gateway.py +150 -0
  409. aria_code/runtime/lsp.py +484 -0
  410. aria_code/runtime/orchestrator.py +55 -0
  411. aria_code/runtime/repo_map.py +700 -0
  412. aria_code/runtime/run_state.py +127 -0
  413. aria_code/runtime/run_store.py +450 -0
  414. aria_code/runtime/self_healing.py +422 -0
  415. aria_code/runtime/subagent.py +547 -0
  416. aria_code/runtime/task_ledger.py +86 -0
  417. aria_code/runtime/task_orchestrator.py +90 -0
  418. aria_code/runtime/tool_executor.py +217 -0
  419. aria_code/runtime/tool_policy.py +107 -0
  420. aria_code/runtime/worktrees.py +215 -0
  421. aria_code/runway_video_client.py +4 -0
  422. aria_code/safety/__init__.py +23 -0
  423. aria_code/safety/permissions.py +306 -0
  424. aria_code/safety/service.py +102 -0
  425. aria_code/setup_wizard.py +673 -0
  426. aria_code/spreadsheet_tools.py +4 -0
  427. aria_code/strategy_vault.py +4 -0
  428. aria_code/tools/__init__.py +0 -0
  429. aria_code/tools/artifact_tools.py +252 -0
  430. aria_code/tools/broker_tools.py +92 -0
  431. aria_code/tools/build_quant_engine.py +97 -0
  432. aria_code/tools/code_audit_tools.py +192 -0
  433. aria_code/tools/computer_use_tools.py +504 -0
  434. aria_code/tools/data_analysis_tools.py +809 -0
  435. aria_code/tools/enterprise_finance_tools.py +156 -0
  436. aria_code/tools/extended_tools.py +240 -0
  437. aria_code/tools/file_analysis_tools.py +885 -0
  438. aria_code/tools/image_gen_tools.py +114 -0
  439. aria_code/tools/local_finance_tools.py +3417 -0
  440. aria_code/tools/logistics_tools.py +135 -0
  441. aria_code/tools/macro_tools.py +369 -0
  442. aria_code/tools/notification_tools.py +249 -0
  443. aria_code/tools/project_tools.py +525 -0
  444. aria_code/tools/realty_data_tools.py +659 -0
  445. aria_code/tools/spreadsheet_tools.py +388 -0
  446. aria_code/tools/stripe_tools.py +115 -0
  447. aria_code/ui/__init__.py +105 -0
  448. aria_code/ui/banner.py +508 -0
  449. aria_code/ui/completer.py +501 -0
  450. aria_code/ui/console.py +281 -0
  451. aria_code/ui/image_render.py +243 -0
  452. aria_code/ui/input_box.py +488 -0
  453. aria_code/ui/picker.py +201 -0
  454. aria_code/ui/render/__init__.py +11 -0
  455. aria_code/ui/render/finance.py +1705 -0
  456. aria_code/ui/render/market.py +225 -0
  457. aria_code/ui/render/output.py +775 -0
  458. aria_code/ui/render/responsive.py +75 -0
  459. aria_code/ui/render/team.py +354 -0
  460. aria_code/ui/robot.py +235 -0
  461. aria_code/ui/startup_dashboard.py +109 -0
  462. aria_code/utils/vscode_uri.py +37 -0
  463. aria_code/video_analysis.py +147 -0
  464. aria_code/video_editor.py +271 -0
  465. aria_code/workspace/__init__.py +6 -0
  466. aria_code/workspace/files.py +193 -0
  467. aria_code/workspace/verify.py +113 -0
  468. aria_code/workspace_changes.py +199 -0
  469. aria_code/workspace_context.py +233 -0
  470. aria_code/workspace_service.py +196 -0
  471. aria_code/workspace_tools.py +150 -0
  472. aria_code-0.52.0.dist-info/METADATA +237 -0
  473. aria_code-0.52.0.dist-info/RECORD +478 -0
  474. aria_code-0.52.0.dist-info/WHEEL +5 -0
  475. aria_code-0.52.0.dist-info/entry_points.txt +3 -0
  476. aria_code-0.52.0.dist-info/licenses/LICENSE +201 -0
  477. aria_code-0.52.0.dist-info/licenses/NOTICE +49 -0
  478. aria_code-0.52.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2520 @@
1
+ """Core slash commands mixin.
2
+
3
+ 本文件绝大多数名字(self.context.console / self.context.has_rich / MODELS …)是有意留作裸名的,由
4
+ aria_cli 的 _rebind_mixin_globals() 把方法 __globals__ 指向它的命名空间来解析。
5
+
6
+ 但 pathlib 不能这么处理:它出现在 _create_scaffold() 的**参数注解**里,而注解
7
+ 在 class 语句执行时就要求值——那时候 rebind 还没发生(rebind 作用于已定义好的
8
+ 类)。所以这一个必须真的 import。
9
+
10
+ 2026-08-19 发现:此前没有这行,导致本模块在 Python 3.10–3.13 上 import 即
11
+ NameError,而这正是 requires-python = "<3.14,>=3.10" 声明支持的全部范围。
12
+ 开发机 .venv 是 3.14(PEP 649 注解惰性求值)所以一直没暴露,1352 个测试也
13
+ 全绿。已发布的 4.3.0 同样中招:pip install 后运行 aria-code 直接崩溃。
14
+ """
15
+
16
+ from ._ui import print_error
17
+
18
+ import pathlib
19
+
20
+
21
+ import json
22
+ import asyncio
23
+ import datetime
24
+ import time
25
+ import shlex
26
+ import sys
27
+ import os
28
+ from typing import Dict, Any, Optional
29
+
30
+
31
+ import json
32
+ import asyncio
33
+ import datetime
34
+ import time
35
+ import shlex
36
+ import sys
37
+ import os
38
+ from typing import Dict, Any, Optional
39
+
40
+
41
+ class CoreCommandsMixin:
42
+ def _cmd_rewind_unavailable(self, args: str):
43
+ # cmd_rewind lands with the checkpoint store (runtime/checkpoints.py,
44
+ # still a separate, uncommitted change) — degrade instead of crashing
45
+ # SlashCommands construction when it isn't present yet.
46
+ msg = "/rewind is not available in this build yet."
47
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
48
+ def is_command(self, text: str) -> bool:
49
+ if not text.startswith("/"):
50
+ return False
51
+ # Only match registered commands and skills, not file paths like /Users/...
52
+ cmd = text.split(maxsplit=1)[0].lower()
53
+ return cmd in self.commands or cmd in self.skill_map
54
+ async def execute(self, text: str):
55
+ from aria_code.apps.cli.helpers import _fuzzy_match
56
+ reference_service = getattr(self.terminal, "_reference_service", None)
57
+ if reference_service is not None and "@" in text:
58
+ prepared = reference_service.prepare(text)
59
+ if prepared.errors:
60
+ self.terminal._print_reference_errors(prepared)
61
+ return
62
+ if prepared.references:
63
+ self.terminal._print_reference_summary(prepared)
64
+ text = prepared.expanded_text
65
+ parts = text.split(maxsplit=1)
66
+ cmd_name = parts[0].lower()
67
+ args = parts[1] if len(parts) > 1 else ""
68
+
69
+ if cmd_name in self.commands:
70
+ handler, _ = self.commands[cmd_name]
71
+ try:
72
+ result = handler(args)
73
+ if asyncio.iscoroutine(result):
74
+ await result
75
+ except KeyboardInterrupt:
76
+ if self.context.has_rich:
77
+ self.context.console.print("\n[dim]已取消[/dim]")
78
+ else:
79
+ print("\n已取消")
80
+ except Exception as _cmd_err:
81
+ import traceback as _tb
82
+ _tb_str = _tb.format_exc()
83
+ if self.context.has_rich:
84
+ from rich.panel import Panel as _P
85
+ from rich import box as _rbox
86
+ self.context.console.print(_P(
87
+ f"[red]{type(_cmd_err).__name__}: {_cmd_err}[/red]\n"
88
+ f"[dim]{_tb_str.strip()[-800:]}[/dim]",
89
+ title=f"[red]{cmd_name} 崩溃[/red]",
90
+ border_style="red",
91
+ box=_rbox.ROUNDED,
92
+ ))
93
+ else:
94
+ print(f"\n ✗ {cmd_name} error: {_cmd_err}\n{_tb_str}")
95
+ elif cmd_name in self.skill_map:
96
+ await self._execute_skill(self.skill_map[cmd_name], args)
97
+ else:
98
+ # Fuzzy match: suggest closest command
99
+ all_cmds = list(self.commands.keys()) + list(self.skill_map.keys())
100
+ suggestions = _fuzzy_match(cmd_name, all_cmds, max_results=3)
101
+ if self.context.has_rich:
102
+ self.context.console.print(f"[red]Unknown command: {cmd_name}[/red]")
103
+ if suggestions:
104
+ self.context.console.print(f" [dim]Did you mean: {', '.join(suggestions)}?[/dim]")
105
+ else:
106
+ print(f"Unknown command: {cmd_name}")
107
+ if suggestions:
108
+ print(f" Did you mean: {', '.join(suggestions)}?")
109
+ def cmd_help(self, args: str):
110
+ # Contextual help: /help <command>
111
+ from aria_code.apps.cli.skills_catalog import SKILLS
112
+ target = args.strip().lower()
113
+ if target:
114
+ cmd_key = target if target.startswith("/") else f"/{target}"
115
+ if cmd_key in self.commands:
116
+ _, desc = self.commands[cmd_key]
117
+ if self.context.has_rich:
118
+ self.context.console.print()
119
+ self.context.console.print(f" [bold #C08050]{cmd_key}[/bold #C08050] [dim]{desc}[/dim]")
120
+ h = self._COMMAND_HELP.get(cmd_key)
121
+ if h:
122
+ self.context.console.print(f" {h[0]}")
123
+ self.context.console.print()
124
+ self.context.console.print(" [dim]Examples:[/dim]")
125
+ for ex in h[1]:
126
+ self.context.console.print(f" [bold]{ex}[/bold]")
127
+ self.context.console.print()
128
+ else:
129
+ print(f"\n {cmd_key} {desc}")
130
+ return
131
+ # Check skills
132
+ for s in SKILLS:
133
+ if s["command"] == cmd_key:
134
+ if self.context.has_rich:
135
+ self.context.console.print()
136
+ self.context.console.print(f" [bold #C08050]{s['command']}[/bold #C08050] [dim]{s['description']}[/dim]")
137
+ self.context.console.print(f" [dim]Category:[/dim] {s['category']}")
138
+ self.context.console.print()
139
+ else:
140
+ print(f"\n {s['command']} {s['description']}")
141
+ return
142
+ self.context.console.print(f"[dim]No help for: {target}. Try /help[/dim]" if self.context.has_rich else f"No help for: {target}")
143
+ return
144
+
145
+ # Full help listing
146
+ if self.context.has_rich:
147
+ self.context.console.print()
148
+
149
+ # ── Natural language first ──────────────────────────────────────
150
+ self.context.console.print("[bold]Just type what you want[/bold] [dim]— no command needed[/dim]")
151
+ self.context.console.print()
152
+ nl_examples = [
153
+ ("宁德时代今天怎么样?", "How's CATL today?"),
154
+ ("帮我画AAPL的K线图", "Draw a candlestick chart for AAPL"),
155
+ ("分析我的组合风险", "Analyze my portfolio risk"),
156
+ ("生成今日A股晨报", "Generate a morning brief for A-shares"),
157
+ ("NVDA和AMD哪个更值得买?", "NVDA vs AMD — which is a better buy?"),
158
+ ("给我写一个动量回测策略", "Write a momentum backtest strategy"),
159
+ ]
160
+ for zh, en in nl_examples:
161
+ self.context.console.print(f" [#C08050]{zh}[/#C08050] [dim]{en}[/dim]")
162
+ self.context.console.print()
163
+
164
+ # ── Context references ─────────────────────────────────────────
165
+ _ref_is_zh = str(self.terminal.config.get("ui_lang", "en")).lower().startswith("zh")
166
+ _ref_title = "上下文引用" if _ref_is_zh else "Context references"
167
+ _ref_subtitle = (
168
+ "@ 只附加只读上下文,不执行操作"
169
+ if _ref_is_zh else
170
+ "@ attaches read-only context; it never runs an action"
171
+ )
172
+ self.context.console.print(f"[bold]{_ref_title}[/bold] [dim]({_ref_subtitle})[/dim]")
173
+ self.context.console.print()
174
+ for example, description_en, description_zh in (
175
+ ("@file:src/model.py", "file pointer → read_file", "文件引用 → read_file"),
176
+ ("@folder:apps/cli", "folder pointer → list_files", "目录引用 → list_files"),
177
+ ("@asset:AAPL", "market asset", "市场资产"),
178
+ ("@portfolio:core", "saved portfolio", "已保存投资组合"),
179
+ ("@strategy:momentum-v2", "saved strategy", "已保存策略"),
180
+ ("@dataset:prices", "local dataset", "本地数据集"),
181
+ ("@run:walk-forward-42", "research run", "研究运行"),
182
+ ("@report:daily", "generated report", "生成报告"),
183
+ ):
184
+ description = description_zh if _ref_is_zh else description_en
185
+ self.context.console.print(f" [#C08050]{example:27s}[/#C08050] [dim]{description}[/dim]")
186
+ _combine = "组合使用" if _ref_is_zh else "Combine them"
187
+ self.context.console.print(f" [dim]{_combine}: /risk @portfolio:core · /review @folder:apps/cli[/dim]")
188
+ self.context.console.print()
189
+
190
+ # ── Slash commands — grouped ────────────────────────────────────
191
+ self.context.console.print("[bold]Slash commands[/bold] [dim](for direct actions and mode switches)[/dim]")
192
+ self.context.console.print()
193
+ groups = [
194
+ ("Session", ["/help","/clear","/compact","/cost","/status","/health",
195
+ "/regen","/undo","/rewind","/copy","/recap","/btw",
196
+ "/save","/load","/sessions","/export","/export-pdf"]),
197
+ ("Config", ["/model","/thinking","/config","/privacy","/local",
198
+ "/setup","/apikey","/doctor","/mcp"]),
199
+ ("Data", ["/alert","/journal","/watch","/note","/todo","/memory",
200
+ "/artifacts","/strategy","/accuracy"]),
201
+ ("Broker", ["/broker","/paper","/trade","/account","/positions","/orders"]),
202
+ ("Code", ["/project","/init","/review","/code","/plan","/run",
203
+ "/read","/write","/edit","/ls","/search","/verify",
204
+ "/scaffold","/apply","/changes"]),
205
+ ("Quant", ["/backtest","/wf","/compare","/auto-strategy","/execution"]),
206
+ ("UI", ["/ui","/vision","/file"]),
207
+ ("Info", ["/skills","/services","/tools","/providers","/ariarc",
208
+ "/packages","/datasource"]),
209
+ ]
210
+ for group_name, cmd_names in groups:
211
+ visible = [n for n in cmd_names if n in self.commands]
212
+ if not visible:
213
+ continue
214
+ self.context.console.print(f" [dim]{group_name}[/dim]")
215
+ for name in visible:
216
+ _, desc = self.commands[name]
217
+ self.context.console.print(f" [bold #C08050]{name:18s}[/bold #C08050][dim]{desc}[/dim]")
218
+ self.context.console.print()
219
+
220
+ # ── Skills ─────────────────────────────────────────────────────
221
+ self.context.console.print("[bold]Skills[/bold] [dim](type the command or just describe what you want)[/dim]")
222
+ self.context.console.print()
223
+ categories: dict = {}
224
+ for s in SKILLS:
225
+ categories.setdefault(s["category"], []).append(s)
226
+ for cat, skills in categories.items():
227
+ self.context.console.print(f" [dim]{cat}[/dim]")
228
+ for s in skills:
229
+ self.context.console.print(f" [bold #C08050]{s['command']:20s}[/bold #C08050][dim]{s['description']}[/dim]")
230
+ self.context.console.print()
231
+
232
+ # ── Keys ───────────────────────────────────────────────────────
233
+ self.context.console.print("[dim]ESC cancel · Ctrl+D exit · ↑↓ history · Tab autocomplete · \"\"\" multiline[/dim]")
234
+ self.context.console.print()
235
+
236
+ else:
237
+ print("\nJust type what you want — or use a slash command:\n")
238
+ for name, (_, desc) in self.commands.items():
239
+ if name in self._visible_cmds:
240
+ print(f" {name:18s} {desc}")
241
+ print("\nSkills:")
242
+ for s in SKILLS:
243
+ print(f" {s['command']:20s} {s['description']}")
244
+ async def cmd_artifacts(self, args: str):
245
+ from aria_code.ui.render.output import display_path as _display_path
246
+ from aria_code.apps.cli.helpers import _copy_text_to_clipboard, _open_path_or_url, _reveal_path_in_finder
247
+ tokens = [part for part in args.split() if part]
248
+ mode = "list"
249
+ limit = 20
250
+ keep = 20
251
+ dry_run = False
252
+ selector = "latest"
253
+ if tokens:
254
+ head = tokens[0].lower()
255
+ if head in {"prune", "cleanup", "gc", "purge"}:
256
+ mode = "prune"
257
+ for token in tokens[1:]:
258
+ if token == "--dry-run":
259
+ dry_run = True
260
+ continue
261
+ try:
262
+ keep = int(token)
263
+ except Exception:
264
+ continue
265
+ elif head in {"stats", "summary"}:
266
+ mode = "stats"
267
+ elif head in {"open", "reveal", "show", "path", "copy-path", "copy"}:
268
+ mode = "copy-path" if head == "copy" else head
269
+ selector = tokens[1] if len(tokens) > 1 else "latest"
270
+ else:
271
+ try:
272
+ limit = int(head)
273
+ except Exception:
274
+ limit = 20
275
+ from artifacts import artifact_summary_all, prune_artifacts_all, recent_artifacts_all
276
+
277
+ if mode in {"open", "reveal", "show", "path", "copy-path"}:
278
+ items = recent_artifacts_all(limit=100)
279
+ selected = None
280
+ if items:
281
+ if selector.lower() in {"latest", "last", "newest", "最近", "最新"}:
282
+ selected = items[0]
283
+ elif selector.isdigit() and 1 <= int(selector) <= len(items):
284
+ selected = items[int(selector) - 1]
285
+ else:
286
+ needle = selector.lower()
287
+ selected = next(
288
+ (
289
+ item for item in items
290
+ if needle in pathlib.Path(str(item.get("path") or "")).name.lower()
291
+ ),
292
+ None,
293
+ )
294
+ target = str((selected or {}).get("path") or "").strip()
295
+ if not target:
296
+ msg = f"未找到产物 {selector!r}。先运行 /artifacts 查看可用编号。"
297
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
298
+ return
299
+
300
+ ok = True
301
+ error = ""
302
+ if mode == "open":
303
+ ok, error = _open_path_or_url(target)
304
+ action_text = "已打开"
305
+ elif mode in {"reveal", "show"}:
306
+ ok, error = _reveal_path_in_finder(target)
307
+ action_text = "已在访达中定位"
308
+ elif mode == "copy-path":
309
+ ok, error = _copy_text_to_clipboard(target)
310
+ action_text = "路径已复制"
311
+ else:
312
+ action_text = "文件路径"
313
+ if ok:
314
+ msg = f"{action_text}:{target}"
315
+ self.terminal._pending_market_artifact = {
316
+ "kind": str((selected or {}).get("kind") or "artifact"),
317
+ "path": target,
318
+ }
319
+ if self.context.has_rich:
320
+ self.context.console.print(f"[green]✓[/green] {msg}")
321
+ else:
322
+ print(msg)
323
+ else:
324
+ msg = f"操作失败:{error or target}"
325
+ if self.context.has_rich:
326
+ self.context.console.print(f"[red]✗[/red] {msg}")
327
+ else:
328
+ print(msg)
329
+ return
330
+
331
+ if mode == "stats":
332
+ summary = artifact_summary_all()
333
+ roots = [pathlib.Path(str(r)) for r in summary.get("roots") or []]
334
+ total = int(summary.get("total") or 0)
335
+ total_size = int(summary.get("total_size_bytes") or 0)
336
+ by_kind = summary.get("by_kind") or {}
337
+ if self.context.has_rich:
338
+ self.context.console.print("[bold]Artifact inventory[/bold]")
339
+ for r in roots:
340
+ self.context.console.print(f" [dim]root[/dim]: {_display_path(r, fallback=r.name)}")
341
+ self.context.console.print(f" total: [bold]{total}[/bold] size: [bold]{total_size:,} bytes[/bold]")
342
+ if by_kind:
343
+ for kind, count in by_kind.items():
344
+ self.context.console.print(f" [dim]{kind}[/dim]: {count}")
345
+ else:
346
+ print("Artifact inventory")
347
+ print(f" total: {total}")
348
+ print(f" size: {total_size} bytes")
349
+ for kind, count in by_kind.items():
350
+ print(f" {kind}: {count}")
351
+ return
352
+
353
+ if mode == "prune":
354
+ result = prune_artifacts_all(keep=keep, dry_run=dry_run)
355
+ removed = int(result.get("removed") or 0)
356
+ scanned = int(result.get("scanned") or 0)
357
+ action = "Would remove" if dry_run else "Removed"
358
+ if self.context.has_rich:
359
+ self.context.console.print("[bold]Artifact prune[/bold]")
360
+ for r in result.get("roots") or []:
361
+ self.context.console.print(f" [dim]root[/dim]: {_display_path(r, fallback=pathlib.Path(str(r)).name)}")
362
+ self.context.console.print(f" keep: [bold]{result.get('keep', keep)}[/bold] scanned: [bold]{scanned}[/bold] removed: [bold]{removed}[/bold]")
363
+ if removed:
364
+ for entry in result.get("deleted") or []:
365
+ name = pathlib.Path(str(entry.get("path") or entry.get("metadata_path") or "")).name
366
+ root_name = pathlib.Path(str(entry.get("root") or "")).name
367
+ self.context.console.print(f" [dim]{action}[/dim] {name} [dim]({root_name})[/dim]")
368
+ else:
369
+ print("Artifact prune")
370
+ print(f" keep: {result.get('keep', keep)}")
371
+ print(f" scanned: {scanned}")
372
+ print(f" removed: {removed}")
373
+ for entry in result.get("deleted") or []:
374
+ name = pathlib.Path(str(entry.get("path") or entry.get("metadata_path") or "")).name
375
+ root_name = pathlib.Path(str(entry.get("root") or "")).name
376
+ print(f" {action}: {name} ({root_name})")
377
+ return
378
+
379
+ items = recent_artifacts_all(limit=limit)
380
+ if not items:
381
+ msg = "No artifacts found"
382
+ self.context.console.print(f"[dim]{msg}[/dim]") if self.context.has_rich else print(msg)
383
+ return
384
+
385
+ title = "生成的产物" if str(self.terminal.config.get("ui_lang", "en")).lower().startswith("zh") else "Generated artifacts"
386
+ if self.context.has_rich:
387
+ self.context.console.print(f"[bold]{title}[/bold]")
388
+ else:
389
+ print(title)
390
+ for index, item in enumerate(items, start=1):
391
+ _path = item.get("path") or item.get("metadata_path") or ""
392
+ _name = pathlib.Path(str(_path)).name if _path else ""
393
+ _root_name = pathlib.Path(str(item.get("root") or "")).name if item.get("root") else ""
394
+ _status = str(item.get("status") or "unknown")
395
+ _icon = "✓" if _status == "complete" else "!" if _status in {"partial", "failed"} else "·"
396
+ _kind = str(item.get("kind") or "artifact")
397
+ _topic = str(item.get("topic") or "").strip()
398
+ _headline = f"#{index} {_icon} {_kind}" + (f" · {_topic}" if _topic else "")
399
+ _detail = f" {_name}" + (f" · {_root_name}" if _root_name else "")
400
+ if self.context.has_rich:
401
+ self.context.console.print(f"[bold]{_headline}[/bold]")
402
+ self.context.console.print(f"[dim]{_detail}[/dim]")
403
+ else:
404
+ print(_headline)
405
+ print(_detail)
406
+ hint = "操作:/artifacts open 1 · reveal 1 · copy-path 1"
407
+ self.context.console.print(f"[dim]{hint}[/dim]") if self.context.has_rich else print(hint)
408
+ async def _run_in_executor(self, fn, *args):
409
+ import asyncio
410
+ loop = asyncio.get_event_loop()
411
+ result = await loop.run_in_executor(None, fn, *args)
412
+ return result
413
+ def cmd_plan(self, args: str):
414
+ from aria_cli import OpsCommandsMixin, _PLAN_MODE
415
+ sub = args.strip().lower().split()[0] if args.strip() else ""
416
+
417
+ if sub in ("mode", "enter", "on", "start"):
418
+ _PLAN_MODE.enter()
419
+ if self.context.has_rich:
420
+ self.context.console.print()
421
+ self.context.console.print(
422
+ " [bold cyan]◆ 计划模式已激活[/bold cyan] "
423
+ "[dim]每个工具调用前都会显示确认提示[/dim]"
424
+ )
425
+ self.context.console.print(" [dim]/plan exit — 退出计划模式[/dim]")
426
+ self.context.console.print()
427
+ else:
428
+ print(" Plan mode ON — every tool call will ask for approval.")
429
+ return
430
+
431
+ if sub in ("exit", "off", "stop", "quit"):
432
+ summary = _PLAN_MODE.summary()
433
+ _PLAN_MODE.exit()
434
+ if self.context.has_rich:
435
+ self.context.console.print(
436
+ f" [dim]◆ 计划模式已退出 "
437
+ f"(共 {summary['total']} 步 · "
438
+ f"[green]{summary['approved']} 执行[/green] · "
439
+ f"[red]{summary['rejected']} 跳过[/red])[/dim]"
440
+ )
441
+ else:
442
+ print(f" Plan mode OFF. ({summary['total']} steps, {summary['approved']} approved)")
443
+ return
444
+
445
+ if sub == "status":
446
+ if _PLAN_MODE.active:
447
+ summary = _PLAN_MODE.summary()
448
+ msg = (
449
+ f"计划模式: [bold cyan]激活[/bold cyan] "
450
+ f"(已执行 {summary['approved']} 步,跳过 {summary['rejected']} 步)"
451
+ )
452
+ else:
453
+ msg = "计划模式: [dim]未激活[/dim] (/plan mode 开启)"
454
+ self.context.console.print(f" {msg}") if self.context.has_rich else print(f" {msg.replace('[bold cyan]', '').replace('[/bold cyan]', '').replace('[dim]', '').replace('[/dim]', '')}")
455
+ return
456
+
457
+ return OpsCommandsMixin.cmd_plan(self, args)
458
+ def cmd_tasks(self, args: str):
459
+ """Show and manage background subagent tasks."""
460
+ try:
461
+ from runtime.subagent import _TASKS, tool_task_cancel
462
+ except ImportError:
463
+ msg = "Subagent module not available."
464
+ self.context.console.print(f"[red]{msg}[/red]") if self.context.has_rich else print(msg)
465
+ return
466
+
467
+ parts = args.strip().split()
468
+ sub = parts[0].lower() if parts else "list"
469
+
470
+ if sub == "cancel" and len(parts) > 1:
471
+ result = tool_task_cancel({"task_id": parts[1]})
472
+ if result.get("success"):
473
+ msg = f"✓ Task {parts[1]} cancelled"
474
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
475
+ else:
476
+ self.context.console.print(f"[red]{result.get('error', 'Error')}[/red]") if self.context.has_rich else print(result.get("error"))
477
+ return
478
+
479
+ # Default: list all tasks
480
+ tasks = list(_TASKS.values())
481
+ if not tasks:
482
+ msg = "没有活跃的后台任务。使用 spawn_task 工具创建任务。"
483
+ self.context.console.print(f" [dim]{msg}[/dim]") if self.context.has_rich else print(msg)
484
+ return
485
+
486
+ if self.context.has_rich:
487
+ self.context.console.print()
488
+ self.context.console.print(" [bold]后台任务[/bold]")
489
+ self.context.console.print()
490
+ status_colors = {"pending": "yellow", "running": "cyan", "done": "green",
491
+ "failed": "red", "cancelled": "dim"}
492
+ for t in tasks:
493
+ col = status_colors.get(t.status, "white")
494
+ preview = t.prompt[:60] + ("…" if len(t.prompt) > 60 else "")
495
+ self.context.console.print(
496
+ f" [{col}]●[/{col}] [bold]{t.task_id}[/bold] "
497
+ f"[{col}]{t.status:10s}[/{col}] "
498
+ f"[dim]{t.age_str():>5s}[/dim] {preview}"
499
+ )
500
+ self.context.console.print()
501
+ self.context.console.print(" [dim]/tasks cancel <id> — 取消任务[/dim]")
502
+ self.context.console.print()
503
+ else:
504
+ print(f"\n Background Tasks ({len(tasks)}):")
505
+ for t in tasks:
506
+ preview = t.prompt[:50]
507
+ print(f" {t.task_id} {t.status:10s} {t.age_str():>5s} {preview}")
508
+ def cmd_delegate(self, args: str):
509
+ """Delegate a task to the Claude Code or Codex CLI as a background subagent."""
510
+ try:
511
+ from runtime.subagent import tool_spawn_task
512
+ except ImportError:
513
+ msg = "Subagent module not available."
514
+ self.context.console.print(f"[red]{msg}[/red]") if self.context.has_rich else print(msg)
515
+ return
516
+
517
+ parts = args.strip().split(maxsplit=1)
518
+ if len(parts) < 2 or parts[0].lower() not in {"claude", "codex"}:
519
+ msg = 'Usage: /delegate claude|codex "<prompt>"'
520
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
521
+ return
522
+
523
+ backend, prompt = parts[0].lower(), parts[1].strip().strip('"')
524
+ result = tool_spawn_task({
525
+ "prompt": prompt,
526
+ "backend": backend,
527
+ "mode": "workspace-write",
528
+ "isolation": "auto",
529
+ })
530
+ if not result.get("success"):
531
+ self.context.console.print(f"[red]{result.get('error', 'Error')}[/red]") if self.context.has_rich else print(result.get("error"))
532
+ return
533
+ task_id = result["task_id"]
534
+ msg = f"✓ Delegated to {backend}: task {task_id}. Check with /tasks or task_status('{task_id}')."
535
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
536
+ def cmd_canva(self, args: str):
537
+ """Manage the Canva Connect integration used for report design drafts."""
538
+ parts = args.strip().split(maxsplit=2)
539
+ sub = parts[0].lower() if parts else "status"
540
+
541
+ if sub == "connect":
542
+ if len(parts) < 3:
543
+ msg = "Usage: /canva connect <client_id> <client_secret> (register an app first at https://www.canva.com/developers/)"
544
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
545
+ return
546
+ client_id, client_secret = parts[1], parts[2]
547
+ from canva_client import connect as _canva_connect
548
+ msg = "打开浏览器完成 Canva 授权…"
549
+ self.context.console.print(f"[cyan]{msg}[/cyan]") if self.context.has_rich else print(msg)
550
+ result = _canva_connect(client_id, client_secret)
551
+ if result.get("success"):
552
+ msg = "✓ Canva 已连接"
553
+ self.context.console.print(f"[green]{msg}[/green]") if self.context.has_rich else print(msg)
554
+ else:
555
+ self.context.console.print(f"[red]{result.get('error')}[/red]") if self.context.has_rich else print(result.get("error"))
556
+ return
557
+
558
+ if sub == "status":
559
+ from canva_client import _load_canva_config
560
+ entry = _load_canva_config()
561
+ msg = "✓ Canva 已连接" if entry.get("access_token") else "未连接 Canva。运行 /canva connect <client_id> <client_secret>"
562
+ self.context.console.print(msg) if self.context.has_rich else print(msg)
563
+ return
564
+
565
+ msg = "Usage: /canva connect <client_id> <client_secret> | /canva status"
566
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
567
+ def _confirm_high_risk_command(self, command: str, risk: str, policy: str) -> bool:
568
+ """Double-confirm high-risk commands even if policy allows them."""
569
+ msg = f"High-risk command under policy '{policy}' (risk={risk}): {command}\nRun it? [y/N]: "
570
+ try:
571
+ answer = self.context.console.input(msg) if self.context.has_rich else input(msg)
572
+ except (EOFError, KeyboardInterrupt):
573
+ return False
574
+ return answer.strip().lower() in {"y", "yes"}
575
+ def _open_file(self, path: str):
576
+ """Open a local file using platform default app."""
577
+ path_q = shlex.quote(path)
578
+ if sys.platform == "darwin":
579
+ os.system(f"open {path_q}")
580
+ elif os.name == "nt":
581
+ os.system(f'start "" {path_q}')
582
+ else:
583
+ os.system(f"xdg-open {path_q} >/dev/null 2>&1")
584
+ async def _smart_compact_async(self, silent: bool = False):
585
+ """AI-powered conversation compaction (inspired by Claude Code).
586
+
587
+ Sends all messages to the current model and asks for a dense summary,
588
+ then replaces conversation with [system summary] + last 2 message pairs.
589
+ Falls back to hard trim if the summary call fails.
590
+ """
591
+ from aria_cli import OllamaProvider, stream_provider_result
592
+ from aria_code.apps.cli.model_catalog import get_model_cfg
593
+ conv = self.terminal.conversation
594
+ if len(conv) <= 4:
595
+ if not silent:
596
+ self.context.console.print("[dim]Context small enough — no compaction needed[/dim]" if self.context.has_rich
597
+ else "Context small enough")
598
+ return
599
+
600
+ if not silent and self.context.has_rich:
601
+ self.context.console.print("[dim]Summarising conversation...[/dim]")
602
+
603
+ from packages.aria_services.context import build_context_service
604
+
605
+ model_key = self.terminal.config.get("model", "qwen2.5:7b")
606
+ max_ctx = int(get_model_cfg(model_key).get("num_ctx", 16384) or 16384)
607
+ context_service = build_context_service(max_tokens=max_ctx)
608
+ summary_prompt = context_service.build_summary_prompt(conv)
609
+
610
+ summary = ""
611
+ try:
612
+ result = await stream_provider_result(
613
+ OllamaProvider(
614
+ self.terminal.config.get("ollama_url", "http://localhost:11434"),
615
+ self.terminal.config.get("model", "qwen2.5:7b"),
616
+ show_market_prefetch_status=False,
617
+ ),
618
+ summary_prompt,
619
+ [], # no history — pure summarisation task
620
+ tools=[],
621
+ )
622
+ if result.get("success") and result.get("response"):
623
+ summary = result["response"].strip()
624
+ except Exception:
625
+ pass
626
+
627
+ if not summary:
628
+ # Fallback: local structural compaction before hard trimming.
629
+ try:
630
+ compacted = context_service.compact_messages(conv)
631
+ except Exception:
632
+ compacted = []
633
+ self.terminal.conversation = compacted if compacted and len(compacted) < len(conv) else conv[-8:]
634
+ if not silent:
635
+ self.context.console.print("[dim]Compacted (summary failed, used local fallback)[/dim]" if self.context.has_rich
636
+ else "Compacted (summary fallback)")
637
+ return
638
+
639
+ envelope = context_service.build_summary_envelope(conv, summary)
640
+ self.terminal.conversation = envelope.messages
641
+ new_count = len(self.terminal.conversation)
642
+ old_count = len(conv)
643
+ if not silent:
644
+ if self.context.has_rich:
645
+ self.context.console.print(
646
+ f" [dim]✓ Compacted {old_count} → {new_count} messages "
647
+ f"(summary preserved context)[/dim]"
648
+ )
649
+ else:
650
+ print(f"Compacted {old_count} → {new_count} messages")
651
+ def cmd_doctor(self, args: str):
652
+ from aria_cli import DiagnosticOpsCommandsMixin
653
+ _r = DiagnosticOpsCommandsMixin.cmd_doctor(self, args)
654
+ # Architecture coverage summary (observability layer) — /architecture for the
655
+ # full layered view. Best-effort; never let it break /doctor.
656
+ try:
657
+ from packages.aria_core import architecture_gaps, architecture_status_counts
658
+ _c = architecture_status_counts()
659
+ _done, _total = _c.get("done", 0), sum(_c.values())
660
+ _line = (f"架构契约: {_done}/{_total} 层完成 · {len(architecture_gaps())} 层待办"
661
+ f" (/architecture 看详情, --gaps 只看待办)")
662
+ self.context.console.print(f"\n [dim]{_line}[/dim]") if self.context.has_rich else print(f"\n {_line}")
663
+ except Exception:
664
+ pass
665
+ return _r
666
+ @staticmethod
667
+ def _create_scaffold(target_dir: pathlib.Path, template: dict) -> list:
668
+ """Create dirs + write files from a scaffold template. Returns list of created paths."""
669
+ created = []
670
+ for d in template.get("dirs", []):
671
+ dp = target_dir / d
672
+ dp.mkdir(parents=True, exist_ok=True)
673
+ created.append(str(dp))
674
+ for rel, content in template.get("files", {}).items():
675
+ fp = target_dir / rel
676
+ fp.parent.mkdir(parents=True, exist_ok=True)
677
+ if not fp.exists():
678
+ fp.write_text(content, encoding="utf-8")
679
+ created.append(str(fp))
680
+ return created
681
+ def cmd_verify(self, args: str):
682
+ """Infer and run focused verification checks."""
683
+ from aria_cli import VerificationPlanner, _SYNTAX_THEME, _tool_run_command
684
+ from ._ui import Syntax
685
+ parts = args.split()
686
+ dry_run = "--dry-run" in parts
687
+ paths = [p for p in parts if p != "--dry-run"]
688
+ plan = VerificationPlanner(pathlib.Path.cwd()).infer(paths)
689
+ if not plan.commands:
690
+ msg = "No verification command inferred."
691
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
692
+ return
693
+ if self.context.has_rich:
694
+ self.context.console.print(f"[dim]Verification plan: {plan.reason}[/dim]")
695
+ for idx, command in enumerate(plan.commands, 1):
696
+ self.context.console.print(f" [bold]{idx}.[/bold] {command}")
697
+ else:
698
+ print(f"Verification plan: {plan.reason}")
699
+ for idx, command in enumerate(plan.commands, 1):
700
+ print(f" {idx}. {command}")
701
+ if dry_run:
702
+ return
703
+ for command in plan.commands:
704
+ result = _tool_run_command({
705
+ "command": command,
706
+ "policy": "balanced",
707
+ "permission_mode": self.terminal.config.get("permission_mode", "workspace-write"),
708
+ "network_enabled": bool(self.terminal.config.get("network_enabled", True)),
709
+ "user_approved": True,
710
+ "timeout": 300,
711
+ })
712
+ if not result.get("success"):
713
+ self.context.console.print(f"[red]Verification failed: {command}[/red]" if self.context.has_rich else f"Verification failed: {command}")
714
+ self.context.console.print(f"[red]{result.get('error', '')}[/red]" if self.context.has_rich else result.get("error", ""))
715
+ return
716
+ data = result.get("data", {})
717
+ if data.get("stdout"):
718
+ self.context.console.print(Syntax(data["stdout"], "text", theme=_SYNTAX_THEME) if self.context.has_rich else data["stdout"])
719
+ if data.get("stderr"):
720
+ self.context.console.print(f"[yellow]{data['stderr']}[/yellow]" if self.context.has_rich else data["stderr"])
721
+ msg = "Verification passed."
722
+ self.context.console.print(f"[green]{msg}[/green]" if self.context.has_rich else msg)
723
+ def cmd_run(self, args: str):
724
+ """Run a command: /run <command>"""
725
+ from aria_cli import _SYNTAX_THEME, _tool_run_command
726
+ from aria_code.safety import SafetyService
727
+ from ._ui import Syntax
728
+ if not args.strip():
729
+ self.context.console.print("[dim]Usage: /run [--dry-run] <command>[/dim]" if self.context.has_rich
730
+ else "Usage: /run [--dry-run] <command>")
731
+ return
732
+ text = args.strip()
733
+ dry_run = False
734
+ if text.startswith("--dry-run "):
735
+ dry_run = True
736
+ text = text[len("--dry-run "):].strip()
737
+ if not text:
738
+ self.context.console.print("[dim]Usage: /run [--dry-run] <command>[/dim]" if self.context.has_rich
739
+ else "Usage: /run [--dry-run] <command>")
740
+ return
741
+
742
+ policy = self.terminal.config.get("command_policy", "safe")
743
+ # Built per call, not held: self.terminal.config is mutated in place by
744
+ # /config set, so a cached service would answer with a stale mode.
745
+ decision = SafetyService(self.terminal.config).evaluate_command(text, policy)
746
+ if not dry_run and decision.allowed and decision.risk == "high":
747
+ if not self._confirm_high_risk_command(decision.normalized_command, decision.risk, policy):
748
+ msg = "Cancelled by user."
749
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
750
+ return
751
+ result = _tool_run_command({
752
+ "command": text,
753
+ "policy": policy,
754
+ "permission_mode": self.terminal.config.get("permission_mode", "workspace-write"),
755
+ "network_enabled": bool(self.terminal.config.get("network_enabled", True)),
756
+ "dry_run": dry_run,
757
+ })
758
+ if result["success"]:
759
+ data = result["data"]
760
+ if dry_run:
761
+ msg = (
762
+ f"Dry run: risk={data.get('risk', '?')} "
763
+ f"policy={data.get('policy', '?')} "
764
+ f"approval={data.get('requires_approval', False)} "
765
+ f"network={data.get('network', False)} "
766
+ f"command={data.get('command', '')}"
767
+ )
768
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
769
+ return
770
+ if data["stdout"]:
771
+ if self.context.has_rich:
772
+ self.context.console.print(Syntax(data["stdout"], "text", theme=_SYNTAX_THEME))
773
+ else:
774
+ print(data["stdout"])
775
+ if data["stderr"]:
776
+ if self.context.has_rich:
777
+ self.context.console.print(f"[red]{data['stderr']}[/red]")
778
+ else:
779
+ print(data["stderr"], file=sys.stderr)
780
+ else:
781
+ self.context.console.print(f"[red]{result['error']}[/red]" if self.context.has_rich else result["error"])
782
+ def cmd_apply(self, args: str):
783
+ """Extract code from last AI response and save to file."""
784
+ from aria_code.apps.cli.helpers import _extract_code_block
785
+ from aria_cli import _SYNTAX_THEME, _tool_write_file
786
+ from ._ui import Syntax
787
+ filename = args.strip()
788
+ last_response = ""
789
+ for msg in reversed(self.terminal.conversation):
790
+ if msg["role"] == "assistant":
791
+ last_response = msg["content"]
792
+ break
793
+ if not last_response:
794
+ self.context.console.print("[dim]No AI response to extract from[/dim]" if self.context.has_rich
795
+ else "No response")
796
+ return
797
+
798
+ code = _extract_code_block(last_response)
799
+ if not code:
800
+ self.context.console.print("[dim]No code block found in last response[/dim]" if self.context.has_rich
801
+ else "No code block found")
802
+ return
803
+
804
+ if not filename:
805
+ # Show code preview and ask for filename
806
+ preview = code[:500] + ("..." if len(code) > 500 else "")
807
+ if self.context.has_rich:
808
+ self.context.console.print(f"\n[dim]Found code block ({len(code.splitlines())} lines):[/dim]")
809
+ self.context.console.print(Syntax(preview, "python", theme=_SYNTAX_THEME))
810
+ else:
811
+ print(f"\nFound code ({len(code.splitlines())} lines):")
812
+ print(preview)
813
+ try:
814
+ filename = (self.context.console.input("\n[bold]>[/bold] Save to: ") if self.context.has_rich
815
+ else input("\nSave to: ")).strip()
816
+ except (EOFError, KeyboardInterrupt):
817
+ return
818
+ if not filename:
819
+ return
820
+
821
+ result = _tool_write_file({"path": filename, "content": code})
822
+ if not result["success"]:
823
+ self.context.console.print(f"[red]{result['error']}[/red]" if self.context.has_rich else result["error"])
824
+ async def cmd_code(self, args: str):
825
+ """Generate code and optionally save to file. Usage: /code <description> [--save file.py]"""
826
+ from aria_code.apps.cli.helpers import _extract_code_block
827
+ from aria_code.ui.render.output import display_path as _display_path
828
+ if not args.strip():
829
+ if self.context.has_rich:
830
+ self.context.console.print("[dim]Usage: /code <description> [--save file.py][/dim]")
831
+ self.context.console.print("[dim]Examples:[/dim]")
832
+ self.context.console.print("[dim] /code momentum strategy for AAPL[/dim]")
833
+ self.context.console.print("[dim] /code portfolio optimizer --save optimizer.py[/dim]")
834
+ self.context.console.print("[dim] /code backtest report generator --save report.py[/dim]")
835
+ else:
836
+ print("Usage: /code <description> [--save file.py]")
837
+ return
838
+
839
+ # Parse --save flag
840
+ save_path = None
841
+ description = args
842
+ if "--save" in args:
843
+ parts = args.split("--save")
844
+ description = parts[0].strip()
845
+ save_path = parts[1].strip() if len(parts) > 1 else None
846
+
847
+ from artifacts import user_generated_dir
848
+ from apps.cli.codegen_paths import resolve_user_code_path
849
+ default_save = resolve_user_code_path(
850
+ description,
851
+ None,
852
+ user_generated_dir=user_generated_dir(),
853
+ )
854
+
855
+ # Build code generation prompt
856
+ prompt = (
857
+ f"Generate complete, production-ready Python code for: {description}\n\n"
858
+ "Requirements:\n"
859
+ "- Include all necessary imports\n"
860
+ "- Add clear inline comments\n"
861
+ "- Include error handling\n"
862
+ "- Use type hints where appropriate\n"
863
+ "- Make it runnable as a standalone script\n\n"
864
+ "Return the code wrapped in ```python``` fences."
865
+ )
866
+
867
+ if self.context.has_rich:
868
+ self.context.console.print(f"[bold]Generating code:[/bold] [bold]{description}[/bold]")
869
+ else:
870
+ print(f"Generating: {description}")
871
+
872
+ # Use best available model for code gen
873
+ original_model = self.terminal.config.get("model", "qwen2.5:7b")
874
+ self.terminal.config["model"] = "qwen2.5:7b"
875
+
876
+ await self.terminal.send_message(prompt)
877
+
878
+ # Restore model
879
+ self.terminal.config["model"] = original_model
880
+
881
+ # Extract code from last AI response and save if requested
882
+ if save_path or description:
883
+ last_response = ""
884
+ for msg in reversed(self.terminal.conversation):
885
+ if msg["role"] == "assistant":
886
+ last_response = msg["content"]
887
+ break
888
+ code = _extract_code_block(last_response)
889
+ if code:
890
+ if save_path:
891
+ _save_path = resolve_user_code_path(
892
+ description,
893
+ save_path,
894
+ user_generated_dir=user_generated_dir(),
895
+ )
896
+ else:
897
+ _save_path = default_save
898
+ _save_path.parent.mkdir(parents=True, exist_ok=True)
899
+ _save_path.write_text(code, encoding="utf-8")
900
+ _save_label = _display_path(str(_save_path))
901
+ if self.context.has_rich:
902
+ self.context.console.print(f"\n[green]Code saved to {_save_label}[/green] "
903
+ f"[dim]({len(code.splitlines())} lines)[/dim]")
904
+ else:
905
+ print(f"\nSaved: {_save_label} ({len(code.splitlines())} lines)")
906
+ else:
907
+ if self.context.has_rich:
908
+ self.context.console.print("[dim]No code block found in response to save[/dim]")
909
+ else:
910
+ print("No code block found to save")
911
+ async def cmd_feedback(self, args: str):
912
+ """Rate the last AI response and store feedback locally by default.
913
+
914
+ Usage: /feedback good|bad [comment]
915
+ /feedback note <comment>
916
+ """
917
+ from aria_cli import CONFIG_DIR
918
+ from aria_code.privacy import FeedbackRecord
919
+ from aria_code.privacy import FeedbackStore
920
+ from aria_code.ui.render.output import display_path as _display_path
921
+ parts = args.strip().split(maxsplit=1)
922
+ vote = parts[0].lower() if parts else ""
923
+ comment = parts[1].strip() if len(parts) > 1 else ""
924
+
925
+ aliases = {
926
+ "good": "positive", "up": "positive", "1": "positive", "+": "positive",
927
+ "bad": "negative", "down": "negative", "0": "negative", "-": "negative",
928
+ "note": "note",
929
+ }
930
+ rating = aliases.get(vote)
931
+ if rating is None or (rating == "note" and not comment):
932
+ self.context.console.print("[dim]Usage: /feedback good|bad [comment] | /feedback note <comment>[/dim]" if self.context.has_rich
933
+ else "Usage: /feedback good|bad [comment] | /feedback note <comment>")
934
+ return
935
+
936
+ # Find last assistant message and its position
937
+ last_msg = None
938
+ msg_idx = None
939
+ for i in range(len(self.terminal.conversation) - 1, -1, -1):
940
+ if self.terminal.conversation[i]["role"] == "assistant":
941
+ last_msg = self.terminal.conversation[i]["content"][:500]
942
+ msg_idx = i
943
+ break
944
+ if not last_msg:
945
+ self.context.console.print("[dim]No AI response to rate[/dim]" if self.context.has_rich else "No response to rate")
946
+ return
947
+
948
+ from aria_code.safety import SafetyService
949
+ settings = SafetyService(self.terminal.config).privacy()
950
+ record = FeedbackRecord.create(
951
+ rating=rating,
952
+ message=last_msg,
953
+ comment=comment,
954
+ model=self.terminal.config.get("model", ""),
955
+ session_id=self.terminal.session_id,
956
+ message_index=msg_idx,
957
+ shared=settings.data_sharing and settings.feedback_upload,
958
+ )
959
+ store = FeedbackStore(CONFIG_DIR)
960
+
961
+ # Persist locally first. This is the default and works offline.
962
+ try:
963
+ feedback_path = store.append(record)
964
+ except Exception as exc:
965
+ msg = f"Could not save feedback locally: {exc}"
966
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
967
+ return
968
+
969
+ # Optional remote upload only after explicit opt-in. Posts to the
970
+ # backend /feedback endpoint in the shape it expects (FeedbackRequest)
971
+ # so the rating reaches production_data_collector → DPO training.
972
+ # No trail_id needed: the backend matches the latest sample for this
973
+ # session_id (good→thumbs_up, bad→thumbs_down; bad+comment→DPO pair).
974
+ api_success = False
975
+ upload_attempted = settings.data_sharing and settings.feedback_upload
976
+ if upload_attempted and rating in ("positive", "negative"):
977
+ try:
978
+ import aiohttp
979
+ headers = {}
980
+ if self.terminal.config.get("auth_token"):
981
+ headers["Authorization"] = f"Bearer {self.terminal.config['auth_token']}"
982
+ payload = {
983
+ "user_id": self.terminal.config.get("user_id") or "cli",
984
+ "session_id": self.terminal.session_id,
985
+ "feedback_type": "thumbs_up" if rating == "positive" else "thumbs_down",
986
+ "response_id": self.terminal.session_id, # latest-in-session match
987
+ "value": comment or None,
988
+ }
989
+ async with aiohttp.ClientSession() as session:
990
+ async with session.post(
991
+ f"{self.terminal.api_url}/feedback",
992
+ json=payload,
993
+ headers=headers,
994
+ timeout=aiohttp.ClientTimeout(total=8)
995
+ ) as resp:
996
+ api_success = resp.status in (200, 201, 204)
997
+ except Exception:
998
+ api_success = False
999
+
1000
+ icon = "↑" if rating == "positive" else ("↓" if rating == "negative" else "note")
1001
+ if upload_attempted:
1002
+ sync_note = "" if api_success else " [dim](saved locally; upload failed)[/dim]"
1003
+ else:
1004
+ sync_note = " [dim](saved locally; sharing off)[/dim]"
1005
+ if self.context.has_rich:
1006
+ comment_note = f" — {comment}" if comment else ""
1007
+ self.context.console.print(f"[green]Feedback {icon}[/green]{comment_note}{sync_note}")
1008
+ self.context.console.print(f"[dim]Saved: {_display_path(feedback_path)}[/dim]")
1009
+ else:
1010
+ print(f"Feedback {icon}" + (f" — {comment}" if comment else "") +
1011
+ (" (uploaded)" if api_success else " (saved locally)"))
1012
+ def cmd_privacy(self, args: str):
1013
+ """Manage local privacy and feedback-sharing settings."""
1014
+ from aria_cli import CONFIG_DIR
1015
+ from aria_code.privacy import FeedbackStore
1016
+ from aria_code.privacy import PrivacySettings
1017
+ from aria_code.ui.render.output import display_path as _display_path
1018
+ parts = args.strip().split(maxsplit=1)
1019
+ sub = parts[0].lower() if parts else "status"
1020
+ rest = parts[1].strip() if len(parts) > 1 else ""
1021
+ store = FeedbackStore(CONFIG_DIR)
1022
+ from aria_code.safety import SafetyService
1023
+ settings = SafetyService(self.terminal.config).privacy()
1024
+
1025
+ def _save_settings(new_settings: PrivacySettings):
1026
+ new_settings.apply_to_config(self.terminal.config)
1027
+ self.context.save_config(self.terminal.config)
1028
+
1029
+ if sub in {"status", "show"}:
1030
+ shared_state = "ON — feedback may be shared with Arthera" if (
1031
+ settings.data_sharing and settings.feedback_upload
1032
+ ) else "OFF — local-only, nothing leaves this machine"
1033
+ lines = [
1034
+ "Privacy status",
1035
+ f" sharing: {shared_state}",
1036
+ f" data_sharing: {settings.data_sharing}",
1037
+ f" feedback_upload: {settings.feedback_upload}",
1038
+ f" feedback_records: {store.count()}",
1039
+ f" local_feedback: {store.feedback_file}",
1040
+ " default: local-only; no upload unless you run /privacy opt-in",
1041
+ " full policy: /privacy policy (or see PRIVACY.md)",
1042
+ ]
1043
+ if self.context.has_rich:
1044
+ self.context.console.print()
1045
+ self.context.console.print("[bold]Privacy[/bold]")
1046
+ for line in lines[1:]:
1047
+ self.context.console.print(f"[dim]{line}[/dim]")
1048
+ else:
1049
+ print("\n".join(lines))
1050
+ return
1051
+
1052
+ if sub == "policy":
1053
+ url = "https://github.com/artherahq/aria-code/blob/aria-code/PRIVACY.md"
1054
+ local = pathlib.Path(__file__).resolve().parent / "PRIVACY.md"
1055
+ lines = [
1056
+ "Aria Code is local-first: by default nothing is collected or uploaded.",
1057
+ "Only `/privacy opt-in` shares feedback (rating + related message) with Arthera.",
1058
+ "Credentials, positions, and financial data always stay on your machine.",
1059
+ f" full policy: {local if local.exists() else url}",
1060
+ " manage: /privacy opt-in | opt-out | export [path] | delete",
1061
+ ]
1062
+ if self.context.has_rich:
1063
+ self.context.console.print()
1064
+ self.context.console.print("[bold]Privacy policy[/bold]")
1065
+ for line in lines:
1066
+ self.context.console.print(f"[dim]{line}[/dim]")
1067
+ else:
1068
+ print("\n".join(lines))
1069
+ return
1070
+
1071
+ if sub in {"opt-in", "on", "enable"}:
1072
+ _save_settings(PrivacySettings(data_sharing=True, feedback_upload=True))
1073
+ if self.context.has_rich:
1074
+ self.context.console.print("[green]Data sharing enabled.[/green]")
1075
+ self.context.console.print("[dim] You consent to share /feedback records (rating, the related[/dim]")
1076
+ self.context.console.print("[dim] model message, optional comment, model id, session id, time)[/dim]")
1077
+ self.context.console.print("[dim] with Arthera to improve the product. Local copies are kept.[/dim]")
1078
+ self.context.console.print("[dim] Credentials & financial data are never shared. Details: /privacy policy[/dim]")
1079
+ self.context.console.print("[dim] Withdraw any time: /privacy opt-out[/dim]")
1080
+ else:
1081
+ print("Data sharing enabled. You consent to share /feedback records "
1082
+ "(rating + related message) with Arthera. Credentials & financial "
1083
+ "data are never shared. Withdraw: /privacy opt-out. Details: /privacy policy")
1084
+ return
1085
+
1086
+ if sub in {"opt-out", "off", "disable"}:
1087
+ _save_settings(PrivacySettings(data_sharing=False, feedback_upload=False))
1088
+ msg = "Data sharing disabled. Feedback stays local only."
1089
+ self.context.console.print(f"[green]{msg}[/green]" if self.context.has_rich else msg)
1090
+ return
1091
+
1092
+ if sub == "export":
1093
+ dest = rest or None
1094
+ try:
1095
+ path = store.export_jsonl(dest)
1096
+ except Exception as exc:
1097
+ msg = f"Export failed: {exc}"
1098
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
1099
+ return
1100
+ msg = f"Exported feedback to {_display_path(path)}"
1101
+ self.context.console.print(f"[green]{msg}[/green]" if self.context.has_rich else msg)
1102
+ return
1103
+
1104
+ if sub in {"delete", "clear"}:
1105
+ count = store.delete_all()
1106
+ msg = f"Deleted {count} local feedback record(s)."
1107
+ self.context.console.print(f"[green]{msg}[/green]" if self.context.has_rich else msg)
1108
+ return
1109
+
1110
+ msg = "Usage: /privacy [status|policy|opt-in|opt-out|export [path]|delete]"
1111
+ self.context.console.print(f"[dim]{msg}[/dim]" if self.context.has_rich else msg)
1112
+ async def _run_tool_cmd(self, tool_name: str, params: dict, label: str = ""):
1113
+ """Generic helper: run tool with spinner and formatted output.
1114
+
1115
+ Routing priority:
1116
+ 1. LOCAL_TOOLS (via executor — never blocks event loop)
1117
+ 2. Remote Aria backend (AWS) — if local not available
1118
+ 3. Graceful error if both fail
1119
+ """
1120
+ from aria_cli import Text
1121
+ from aria_code.apps.cli.tool_executor import execute_aria_tool
1122
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1123
+ display = label or tool_name
1124
+
1125
+ # ── 1. Try LOCAL_TOOLS first (run in executor to avoid blocking) ──
1126
+ if tool_name in LOCAL_TOOLS:
1127
+ handler, _ = LOCAL_TOOLS[tool_name]
1128
+ if self.context.has_rich:
1129
+ with self.context.console.status(f"[dim]{display}...[/dim]", spinner="dots"):
1130
+ result = await asyncio.get_event_loop().run_in_executor(
1131
+ None, handler, params
1132
+ )
1133
+ else:
1134
+ print(f"Running {display}...")
1135
+ result = await asyncio.get_event_loop().run_in_executor(
1136
+ None, handler, params
1137
+ )
1138
+ else:
1139
+ # ── 2. Fall through to remote Aria backend ────────────────────
1140
+ local_mode = self.terminal.config.get("local_mode", False)
1141
+ if local_mode:
1142
+ result = {
1143
+ "success": False,
1144
+ "error": f"Tool '{tool_name}' has no local implementation. "
1145
+ "Run '/local off' to use the Aria backend, or "
1146
+ "add a handler in aria_tools.py.",
1147
+ }
1148
+ else:
1149
+ if self.context.has_rich:
1150
+ with self.context.console.status(f"[dim]Running {display}...[/dim]", spinner="dots"):
1151
+ result = await execute_aria_tool(self.terminal.api_url, tool_name, params)
1152
+ else:
1153
+ print(f"Running {display}...")
1154
+ result = await execute_aria_tool(self.terminal.api_url, tool_name, params)
1155
+
1156
+ if result.get("success"):
1157
+ data = result.get("data", {})
1158
+ if isinstance(data, dict) and self.context.has_rich:
1159
+ out = Text()
1160
+ for k, v in data.items():
1161
+ if k in ("chart_prices", "raw", "metadata"):
1162
+ continue
1163
+ label_str = k.replace("_", " ").title()
1164
+ val_str = f"{v:.4f}" if isinstance(v, float) else str(v)
1165
+ if isinstance(v, (int, float)):
1166
+ color = "green" if v >= 0 else "red" if v < 0 else ""
1167
+ out.append(f" {label_str:<20s}", style="dim")
1168
+ out.append(f"{val_str}\n", style=color if color else "")
1169
+ else:
1170
+ out.append(f" {label_str:<20s}", style="dim")
1171
+ out.append(f"{val_str}\n")
1172
+ self.context.console.print(out)
1173
+ else:
1174
+ self.context.console.print(f" [dim]{json.dumps(data, ensure_ascii=False)[:500]}[/dim]" if self.context.has_rich
1175
+ else json.dumps(data, ensure_ascii=False)[:500])
1176
+ else:
1177
+ print_error(self.context, f"Failed: {result.get('error', 'No data')}")
1178
+ async def _run_parallel(self, tool_name: str,
1179
+ param_list: list,
1180
+ label_fn=None):
1181
+ """Run a tool in parallel for multiple param dicts, display each result."""
1182
+ from aria_cli import _print_finance_result
1183
+ from aria_code.apps.cli.tool_executor import execute_aria_tool
1184
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1185
+ tasks = [
1186
+ asyncio.create_task(
1187
+ asyncio.get_event_loop().run_in_executor(
1188
+ None, LOCAL_TOOLS[tool_name][0], p
1189
+ ) if tool_name in LOCAL_TOOLS
1190
+ else execute_aria_tool(self.terminal.api_url, tool_name, p)
1191
+ )
1192
+ for p in param_list
1193
+ ]
1194
+ results = await asyncio.gather(*tasks, return_exceptions=True)
1195
+ for p, r in zip(param_list, results):
1196
+ lbl = label_fn(p) if label_fn else tool_name
1197
+ if isinstance(r, Exception):
1198
+ print_error(self.context, f"{lbl}: {r}")
1199
+ else:
1200
+ _print_finance_result(tool_name, r)
1201
+ async def _fetch_and_display_finance(self, tool_name: str, params: dict, label: str,
1202
+ mdc_fallback_symbol: str = "") -> bool:
1203
+ """Try tool → local finance tool → market_data_client fallback. Returns True if data shown."""
1204
+ from aria_cli import _print_finance_result
1205
+ from aria_code.apps.cli.tool_executor import execute_aria_tool
1206
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1207
+ from aria_code.apps.cli._optional import HAS_MDC as _HAS_MDC, get_mdc as _get_mdc
1208
+ result = None
1209
+ # 1. LOCAL_TOOLS (ccxt / local finance)
1210
+ if tool_name in LOCAL_TOOLS:
1211
+ fn = LOCAL_TOOLS[tool_name][0]
1212
+ result = await asyncio.get_event_loop().run_in_executor(None, fn, params)
1213
+ # 2. Remote backend
1214
+ if not (result and result.get("success")):
1215
+ result = await execute_aria_tool(self.terminal.api_url, tool_name, params)
1216
+ # 3. MarketDataClient yfinance fallback
1217
+ if not (result and result.get("success")) and _HAS_MDC and mdc_fallback_symbol:
1218
+ try:
1219
+ mdc = _get_mdc()
1220
+ result = mdc.quote(mdc_fallback_symbol)
1221
+ if result.get("success"):
1222
+ result["provider"] = "yfinance"
1223
+ except Exception:
1224
+ pass
1225
+
1226
+ if result and result.get("success"):
1227
+ _print_finance_result(tool_name, result)
1228
+ # Also show basic price line if _print_finance_result didn't handle this tool
1229
+ if tool_name not in ("get_market_data", "get_crypto_data", "get_forex_data"):
1230
+ px = result.get("price", result.get("rate", 0))
1231
+ chg = result.get("change_pct", 0)
1232
+ sign = "+" if chg >= 0 else ""
1233
+ color = "green" if chg >= 0 else "red"
1234
+ prov = result.get("provider", "")
1235
+ if self.context.has_rich and px:
1236
+ self.context.console.print(f" [bold]{label:<12}[/bold] {px} [{color}]{sign}{chg:.2f}%[/{color}] [dim]{prov}[/dim]")
1237
+ return True
1238
+ else:
1239
+ err = (result or {}).get("error") or "数据暂不可用"
1240
+ if self.context.has_rich:
1241
+ self.context.console.print(f" [yellow]⚠ {label}: {err}[/yellow]")
1242
+ else:
1243
+ print(f" ⚠ {label}: {err}")
1244
+ return False
1245
+ async def cmd_risk(self, args: str):
1246
+ """Risk metrics: /risk AAPL or /risk portfolio"""
1247
+ from aria_code.apps.cli.tool_executor import execute_aria_tool
1248
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1249
+ target = args.strip().upper() or "AAPL"
1250
+ if target == "PORTFOLIO":
1251
+ await self._run_tool_cmd("assess_portfolio_risk", {
1252
+ "holdings": self.terminal.config.get("watchlist", ["AAPL", "MSFT"]),
1253
+ }, "portfolio risk")
1254
+ return
1255
+
1256
+ # Try remote tool; fall back to local get_risk_metrics if backend unavailable
1257
+ result = await execute_aria_tool(self.terminal.api_url, "get_risk_metrics", {"symbol": target})
1258
+ if result.get("success"):
1259
+ data = result.get("data", {})
1260
+ if self.context.has_rich:
1261
+ self.context.console.print()
1262
+ for k, v in (data.items() if isinstance(data, dict) else {}.items()):
1263
+ val_str = f"{v:.4f}" if isinstance(v, float) else str(v)
1264
+ color = "green" if isinstance(v, float) and v >= 0 else ("red" if isinstance(v, float) and v < 0 else "")
1265
+ self.context.console.print(f" [dim]{k.replace('_',' ').title():<24s}[/dim] [{color}]{val_str}[/{color}]" if color
1266
+ else f" [dim]{k.replace('_',' ').title():<24s}[/dim] {val_str}")
1267
+ self.context.console.print()
1268
+ elif "get_risk_metrics" in LOCAL_TOOLS:
1269
+ # Local fallback
1270
+ local_fn = LOCAL_TOOLS["get_risk_metrics"][0]
1271
+ local_result = await asyncio.get_event_loop().run_in_executor(None, local_fn, {"symbol": target})
1272
+ if local_result.get("success"):
1273
+ data = local_result.get("data", {})
1274
+ if self.context.has_rich:
1275
+ self.context.console.print()
1276
+ self.context.console.print(f" [bold]{target} Risk Metrics[/bold] [dim](local calculation)[/dim]")
1277
+ self.context.console.print()
1278
+ for k, v in (data.items() if isinstance(data, dict) else {}.items()):
1279
+ val_str = f"{v:.4f}" if isinstance(v, float) else str(v)
1280
+ self.context.console.print(f" [dim]{k.replace('_',' ').title():<24s}[/dim] {val_str}")
1281
+ self.context.console.print()
1282
+ else:
1283
+ print(f" {target} Risk Metrics (local):")
1284
+ for k, v in (data.items() if isinstance(data, dict) else {}.items()):
1285
+ print(f" {k}: {v}")
1286
+ else:
1287
+ self.context.console.print(f"[dim]Risk metrics unavailable for {target}: {local_result.get('error','')}[/dim]") if self.context.has_rich else print(f"Risk unavailable: {local_result.get('error','')}")
1288
+ else:
1289
+ msg = f"⚠ 风险指标服务暂不可用 ({result.get('error','')[:60]})"
1290
+ self.context.console.print(f"[yellow]{msg}[/yellow]") if self.context.has_rich else print(msg)
1291
+ async def cmd_market(self, args: str):
1292
+ """Market overview: /market [indices|sectors]"""
1293
+ from aria_code.apps.cli.tool_executor import execute_aria_tool
1294
+ from aria_code.apps.cli._optional import HAS_MDC as _HAS_MDC, get_mdc as _get_mdc
1295
+ sub = args.strip().lower()
1296
+ if sub == "sectors":
1297
+ await self._run_tool_cmd("get_sector_performance", {}, "sector performance")
1298
+ else:
1299
+ # Try remote tool first; fall back to local MarketDataClient if backend unavailable
1300
+ result = await execute_aria_tool(self.terminal.api_url, "get_market_indices", {})
1301
+ if result and result.get("success"):
1302
+ await self._run_tool_cmd("get_market_indices", {}, "market indices")
1303
+ elif _HAS_MDC:
1304
+ # Local fallback via MarketDataClient.indices()
1305
+ try:
1306
+ mdc = _get_mdc()
1307
+ idx_result = mdc.indices()
1308
+ if idx_result.get("success") and idx_result.get("indices"):
1309
+ if self.context.has_rich:
1310
+ self.context.console.print()
1311
+ self.context.console.print(" [bold]Global Indices[/bold] [dim](local data)[/dim]")
1312
+ self.context.console.print()
1313
+ for name, d in idx_result["indices"].items():
1314
+ price = d.get("price", "N/A")
1315
+ chg = d.get("change_pct", 0)
1316
+ sign = "+" if chg >= 0 else ""
1317
+ color = "green" if chg >= 0 else "red"
1318
+ if self.context.has_rich:
1319
+ self.context.console.print(f" [dim]{name:<20s}[/dim] {price:>10} [{color}]{sign}{chg:.2f}%[/{color}]")
1320
+ else:
1321
+ print(f" {name:<20s} {price:>10} {sign}{chg:.2f}%")
1322
+ else:
1323
+ self.context.console.print("[dim]市场数据暂不可用。请检查网络连接。[/dim]") if self.context.has_rich else print("Market data unavailable.")
1324
+ except Exception as _e:
1325
+ self.context.console.print(f"[dim]本地数据获取失败: {_e}[/dim]") if self.context.has_rich else print(f"Local data error: {_e}")
1326
+ else:
1327
+ self.context.console.print("[dim]后端不可用,本地数据模块未加载。使用 /indices 命令查看实时行情。[/dim]") if self.context.has_rich else print("Backend unavailable. Try /indices.")
1328
+ async def cmd_optimize(self, args: str):
1329
+ """Optimize portfolio: /optimize [symbols...]"""
1330
+ symbols = args.upper().split() if args else self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"])
1331
+ await self._run_tool_cmd("optimize_positions", {
1332
+ "symbols": symbols, "objective": "max_sharpe",
1333
+ }, f"optimizing {len(symbols)} positions")
1334
+ async def cmd_stress(self, args: str):
1335
+ """Stress test: /stress <strategy> [symbol]"""
1336
+ parts = args.split() if args else ["momentum", "SPY"]
1337
+ strategy = parts[0] if parts else "momentum"
1338
+ symbol = parts[1].upper() if len(parts) > 1 else "SPY"
1339
+ await self._run_tool_cmd("stress_test_strategy", {
1340
+ "strategy": strategy, "symbol": symbol,
1341
+ }, f"stress test {strategy}/{symbol}")
1342
+ async def cmd_factors(self, args: str):
1343
+ """Factor analysis: /factors AAPL"""
1344
+ symbol = args.strip().upper() or "AAPL"
1345
+ await self._run_tool_cmd("calculate_factors", {"symbol": symbol}, f"factors {symbol}")
1346
+ async def cmd_factor_lab(self, args: str):
1347
+ """/factor-lab <SYMBOL> [days=252] — 量化因子工作台(动量/波动率/Sharpe/Amihud)"""
1348
+ parts = args.strip().split()
1349
+ symbol = parts[0].upper() if parts else "AAPL"
1350
+ market = "CN" if any(symbol.startswith(p) for p in ("SH", "SZ", "6", "0", "3")) else "US"
1351
+
1352
+ await self._run_tool_cmd(
1353
+ "equity_factor_scores",
1354
+ {"symbol": symbol, "period": "1y", "market": market},
1355
+ f"factor-lab {symbol}",
1356
+ )
1357
+ async def cmd_execution(self, args: str):
1358
+ """/execution <SYMBOL> <buy|sell> <qty> [algo=compare] [price=0] — 执行算法对比"""
1359
+ parts = args.strip().split()
1360
+ if len(parts) < 3:
1361
+ if self.context.has_rich:
1362
+ self.context.console.print("[dim]Usage: /execution AAPL buy 100000 [algo=compare] [price=180][/dim]")
1363
+ return
1364
+
1365
+ symbol = parts[0].upper()
1366
+ side = parts[1].lower()
1367
+ try:
1368
+ total_qty = float(parts[2].replace(",", ""))
1369
+ except ValueError:
1370
+ if self.context.has_rich: self.context.console.print("[red]qty 必须是数字[/red]")
1371
+ return
1372
+
1373
+ algo = "compare"
1374
+ price = 0.0
1375
+ for p in parts[3:]:
1376
+ if p.startswith("algo="):
1377
+ algo = p.split("=", 1)[1]
1378
+ elif p.startswith("price="):
1379
+ try:
1380
+ price = float(p.split("=", 1)[1])
1381
+ except ValueError:
1382
+ pass
1383
+
1384
+ if price <= 0:
1385
+ # 尝试从市场数据获取现价
1386
+ try:
1387
+ import yfinance as yf
1388
+ t = yf.Ticker(symbol)
1389
+ info = t.fast_info
1390
+ price = float(getattr(info, "last_price", 0) or 0)
1391
+ except Exception:
1392
+ price = 100.0
1393
+
1394
+ await self._run_tool_cmd(
1395
+ "execution_schedule",
1396
+ {
1397
+ "symbol": symbol,
1398
+ "side": side,
1399
+ "total_qty": total_qty,
1400
+ "benchmark_price": price,
1401
+ "algo": algo,
1402
+ },
1403
+ f"执行计划 {symbol} {side} {total_qty:,.0f}股",
1404
+ )
1405
+ async def cmd_stat_arb(self, args: str):
1406
+ """/stat-arb <SYMBOL_A> <SYMBOL_B> [period=2y] — 配对协整检验 + 当前 z-score"""
1407
+ from aria_cli import _generate_stat_arb_chart
1408
+ parts = args.strip().split()
1409
+ if len(parts) < 2:
1410
+ if self.context.has_rich:
1411
+ self.context.console.print("[dim]Usage: /stat-arb GLD SLV [period=2y][/dim]")
1412
+ return
1413
+
1414
+ sym_a = parts[0].upper()
1415
+ sym_b = parts[1].upper()
1416
+ period = "2y"
1417
+ for p in parts[2:]:
1418
+ if p.startswith("period="):
1419
+ period = p.split("=", 1)[1]
1420
+
1421
+ await self._run_tool_cmd(
1422
+ "pair_stats",
1423
+ {"symbol_a": sym_a, "symbol_b": sym_b, "period": period},
1424
+ f"配对检验 {sym_a}/{sym_b}",
1425
+ )
1426
+ # Generate interactive z-score chart
1427
+ await asyncio.get_event_loop().run_in_executor(
1428
+ None, lambda: _generate_stat_arb_chart(sym_a, sym_b, period)
1429
+ )
1430
+ async def cmd_compliance(self, args: str):
1431
+ """Compliance check: /compliance <strategy>"""
1432
+ strategy = args.strip() or "momentum"
1433
+ await self._run_tool_cmd("check_strategy_compliance", {
1434
+ "strategy": strategy,
1435
+ }, f"compliance {strategy}")
1436
+ async def cmd_search_web(self, args: str):
1437
+ """Web search: /web <query>"""
1438
+ query = args.strip()
1439
+ if not query:
1440
+ self.context.console.print("[dim]Usage: /web <search query>[/dim]" if self.context.has_rich else "Usage: /web <query>")
1441
+ return
1442
+ await self._run_tool_cmd("web_search", {"query": query}, f"searching: {query[:30]}")
1443
+ def cmd_local(self, args: str):
1444
+ """Toggle local-only mode (skip AWS, always use Ollama)."""
1445
+ cfg = self.terminal.config
1446
+ arg = args.strip().lower()
1447
+ if arg in ("on", "1", "true", "yes"):
1448
+ cfg["local_mode"] = True
1449
+ elif arg in ("off", "0", "false", "no"):
1450
+ cfg["local_mode"] = False
1451
+ else:
1452
+ cfg["local_mode"] = not cfg.get("local_mode", False)
1453
+ self.context.save_config(cfg)
1454
+ state = "ON" if cfg["local_mode"] else "OFF"
1455
+ model = cfg.get("model", "qwen2.5:7b")
1456
+ if self.context.has_rich:
1457
+ color = "green" if cfg["local_mode"] else "yellow"
1458
+ self.context.console.print(f" [{color}]Local mode {state}[/{color}] model=[bold]{model}[/bold] ollama={cfg.get('ollama_url','http://localhost:11434')}")
1459
+ else:
1460
+ print(f" Local mode {state} model={model}")
1461
+ async def cmd_mcp(self, args: str):
1462
+ """Manage MCP servers: /mcp status | /mcp tools | /mcp reload [server]"""
1463
+ from aria_cli import CONFIG_DIR
1464
+ from aria_code.apps.cli._optional import MCP_CONFIG_PATH
1465
+ from aria_code.apps.cli._optional import HAS_MCP as _HAS_MCP
1466
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS, LOCAL_TOOL_SCHEMAS
1467
+ if not _HAS_MCP:
1468
+ self.context.console.print(" [dim]mcp_client.py not available[/dim]" if self.context.has_rich else "MCP not available")
1469
+ return
1470
+ sub = args.strip().lower()
1471
+ reg = self.terminal._mcp_registry
1472
+
1473
+ _parts = sub.split()
1474
+ if _parts and _parts[0] in ("reload", "restart") and len(_parts) > 1:
1475
+ # Per-server reload: restart one subprocess + reset its circuit,
1476
+ # without tearing down every other server.
1477
+ _name = args.strip().split()[1]
1478
+ if not reg:
1479
+ self.context.console.print(" [dim]No MCP servers running[/dim]" if self.context.has_rich else "No MCP servers")
1480
+ return
1481
+ ok = await reg.reload_server(_name)
1482
+ msg = (f"MCP server {_name!r} reloaded" if ok
1483
+ else f"MCP server {_name!r} not found or failed to restart")
1484
+ if self.context.has_rich:
1485
+ self.context.console.print(f" [{'green' if ok else 'red'}]{msg}[/{'green' if ok else 'red'}]")
1486
+ else:
1487
+ print(f" {msg}")
1488
+ return
1489
+
1490
+ if sub in ("reload", "restart"):
1491
+ if reg:
1492
+ await reg.stop_all()
1493
+ self.terminal._mcp_started = False
1494
+ self.terminal._mcp_registry = None
1495
+ if self.context.has_rich:
1496
+ self.context.console.print(" [dim]Restarting MCP servers…[/dim]")
1497
+ from mcp_client import MCPToolRegistry
1498
+ self.terminal._mcp_registry = MCPToolRegistry()
1499
+ results = await self.terminal._mcp_registry.start_all()
1500
+ n = self.terminal._mcp_registry.register_into(LOCAL_TOOLS, LOCAL_TOOL_SCHEMAS, overwrite=True)
1501
+ if self.context.has_rich:
1502
+ self.context.console.print(f" [green]MCP reloaded: {n} tools from {len(results)} servers[/green]")
1503
+ return
1504
+
1505
+ if sub == "tools":
1506
+ if not reg:
1507
+ self.context.console.print(" [dim]No MCP servers running[/dim]" if self.context.has_rich else "No MCP servers")
1508
+ return
1509
+ tools = reg.all_tools()
1510
+ if self.context.has_rich:
1511
+ self.context.console.print(f"\n [bold]MCP Tools[/bold] ({len(tools)} total)\n")
1512
+ for t in tools:
1513
+ self.context.console.print(f" [bold]{t['qualified_name']:40s}[/bold][dim]{t.get('description','')[:60]}[/dim]")
1514
+ self.context.console.print()
1515
+ else:
1516
+ for t in tools:
1517
+ print(f" {t['qualified_name']:40s} {t.get('description','')[:60]}")
1518
+ return
1519
+
1520
+ # Default: status
1521
+ if self.context.has_rich:
1522
+ self.context.console.print()
1523
+ self.context.console.print(" [bold]MCP Servers[/bold]")
1524
+ if not _HAS_MCP:
1525
+ self.context.console.print(" [dim]Not available (mcp_client.py missing)[/dim]")
1526
+ elif not reg:
1527
+ config_path = str(MCP_CONFIG_PATH)
1528
+ self.context.console.print(f" [dim]No servers started. Configure: {config_path}[/dim]")
1529
+ self.context.console.print(" [dim]Example: add quant_engine MCP server pointing to your mcp_server.py[/dim]")
1530
+ else:
1531
+ for s in reg.status():
1532
+ color = "green" if s["running"] else "red"
1533
+ icon = "●" if s["running"] else "○"
1534
+ self.context.console.print(
1535
+ f" [{color}]{icon}[/{color}] [bold]{s['name']:20s}[/bold]"
1536
+ f" [dim]{s['tool_count']} tools {s['description'][:50]}[/dim]"
1537
+ )
1538
+ self.context.console.print()
1539
+ else:
1540
+ if not reg:
1541
+ print(f" No MCP servers. Configure {CONFIG_DIR}/mcp_servers.json")
1542
+ else:
1543
+ for s in reg.status():
1544
+ print(f" {'●' if s['running'] else '○'} {s['name']:20s} {s['tool_count']} tools")
1545
+ def cmd_license(self, args: str):
1546
+ """Show feature license / entitlement status."""
1547
+ try:
1548
+ from licensing import current_license, license_status
1549
+ current_license(refresh=True) # re-read in case a key was just installed
1550
+ st = license_status()
1551
+ except Exception as e:
1552
+ self.context.console.print(f" [yellow]license 模块不可用: {e}[/yellow]" if self.context.has_rich else f"license unavailable: {e}")
1553
+ return
1554
+ tier = st.get("tier", "free")
1555
+ valid = st.get("valid", True)
1556
+ if self.context.has_rich:
1557
+ color = "green" if (tier != "free" and valid) else "dim"
1558
+ self.context.console.print(f" 授权等级: [{color}]{tier}[/{color}] 有效: {valid}"
1559
+ + (f" 到期: {st['exp']}" if st.get("exp") else ""))
1560
+ if st.get("reason"):
1561
+ self.context.console.print(f" [yellow]{st['reason']}[/yellow]")
1562
+ feats = st.get("features") or []
1563
+ self.context.console.print(f" 已解锁: {', '.join(feats) if feats else '仅免费功能'}")
1564
+ self.context.console.print(f" 签名校验模式: {'开启' if st.get('signed_mode') else '关闭(开发/自托管)'}")
1565
+ self.context.console.print(" [dim]免费版含全部核心功能;专业功能配置 ARIA_LICENSE_KEY 或 ~/.arthera/license.json 解锁。[/dim]")
1566
+ else:
1567
+ print(f"license tier={tier} valid={valid} features={st.get('features')}")
1568
+ def cmd_ariarc(self, args: str):
1569
+ """Show or reload .ariarc project configuration."""
1570
+ from aria_cli import _HAS_ARIARC, get_ariarc, reload_ariarc
1571
+ if not _HAS_ARIARC:
1572
+ self.context.console.print(" [dim]ariarc.py not available[/dim]" if self.context.has_rich else "ariarc not available")
1573
+ return
1574
+ if "reload" in args.lower():
1575
+ arc = reload_ariarc()
1576
+ self.terminal.ariarc = arc
1577
+ if self.context.has_rich:
1578
+ if arc.found:
1579
+ self.context.console.print(f" [green]ariarc reloaded: {arc.source_path}[/green]")
1580
+ else:
1581
+ self.context.console.print(" [yellow]No .ariarc found in current directory tree[/yellow]")
1582
+ return
1583
+
1584
+ arc = self.terminal.ariarc or get_ariarc()
1585
+ if self.context.has_rich:
1586
+ self.context.console.print()
1587
+ if not arc.found:
1588
+ self.context.console.print(" [dim]No .ariarc found (create .ariarc in your project root)[/dim]")
1589
+ self.context.console.print()
1590
+ _example = """{
1591
+ "project": "My Quant Strategy",
1592
+ "description": "A-share momentum + mean-reversion strategy",
1593
+ "market": "cn",
1594
+ "default_symbols": ["sh600519", "sh601318", "sz000858"],
1595
+ "system_prompt": "Focus on A-share market mechanics and T+1 constraints.",
1596
+ "context_files": ["README.md"],
1597
+ "auto_context": ["strategy/main.py"],
1598
+ "commands": {
1599
+ "/morning-cn": "生成A股早盘简报,重点关注 {default_symbols}"
1600
+ }
1601
+ }"""
1602
+ self.context.console.print(f" [dim]Example .ariarc:[/dim]\n{_example}")
1603
+ else:
1604
+ d = arc.to_dict()
1605
+ self.context.console.print(f" [bold]Project:[/bold] {arc.project or '(unnamed)'}")
1606
+ self.context.console.print(f" [bold]Source:[/bold] [dim]{d['source_path']}[/dim]")
1607
+ self.context.console.print(f" [bold]Market:[/bold] {arc.market}")
1608
+ if arc.default_symbols:
1609
+ self.context.console.print(f" [bold]Symbols:[/bold] {', '.join(arc.default_symbols)}")
1610
+ if arc.commands:
1611
+ self.context.console.print(f" [bold]Commands:[/bold] {', '.join(arc.commands.keys())}")
1612
+ if arc.tools_blacklist:
1613
+ self.context.console.print(f" [bold]Blocked tools:[/bold] {', '.join(arc.tools_blacklist)}")
1614
+ if arc.auto_context:
1615
+ self.context.console.print(f" [bold]Auto context:[/bold] {', '.join(arc.auto_context)}")
1616
+ self.context.console.print()
1617
+ else:
1618
+ if arc.found:
1619
+ import json as _j
1620
+ print(_j.dumps(arc.to_dict(), indent=2, ensure_ascii=False))
1621
+ async def cmd_signal(self, args: str):
1622
+ """
1623
+ AI trading signal (BUY/SELL/HOLD) from Alibaba Cloud.
1624
+ Usage: /signal sh600519 /signal AAPL US
1625
+ """
1626
+ from aria_code.apps.cli.helpers import _is_ashare_symbol
1627
+ parts = args.strip().split()
1628
+ symbol = parts[0].upper() if parts else "sh600519"
1629
+ market = parts[1].upper() if len(parts) > 1 else ("CN" if _is_ashare_symbol(symbol) else "US")
1630
+ await self._run_tool_cmd("get_ai_signal", {"symbol": symbol, "market": market},
1631
+ f"AI signal {symbol}")
1632
+ async def cmd_predict(self, args: str):
1633
+ """
1634
+ ML return predictions for a list of symbols.
1635
+ Usage: /predict sh600519 sh601318 sz000858
1636
+ """
1637
+ parts = args.strip().split() if args.strip() else ["sh600519"]
1638
+ symbols = [s for s in parts if not s.isdigit() or len(s) == 6]
1639
+ days = 5
1640
+ for p in parts:
1641
+ if p.startswith("d="):
1642
+ try:
1643
+ days = int(p[2:])
1644
+ except ValueError:
1645
+ pass
1646
+ await self._run_tool_cmd("get_predictions",
1647
+ {"symbols": symbols, "prediction_days": days},
1648
+ f"ML predict {len(symbols)} stocks")
1649
+ async def cmd_cloudbt(self, args: str):
1650
+ """
1651
+ Full ML-powered backtest on Alibaba Cloud.
1652
+ Usage: /cloudbt sh600519 sh601318 [model=lightgbm] [months=12] [freq=weekly] [top=3]
1653
+ """
1654
+ parts = args.strip().split() if args.strip() else []
1655
+ symbols = []
1656
+ kwargs: Dict[str, Any] = {}
1657
+ for p in parts:
1658
+ if "=" in p:
1659
+ k, v = p.split("=", 1)
1660
+ kwargs[k] = v
1661
+ else:
1662
+ symbols.append(p)
1663
+ if not symbols:
1664
+ symbols = ["sh600519"]
1665
+ params = {
1666
+ "symbols": symbols,
1667
+ "model_type": kwargs.get("model", "lightgbm"),
1668
+ "months": int(kwargs.get("months", 12)),
1669
+ "rebalance_freq": kwargs.get("freq", "weekly"),
1670
+ "top_k": int(kwargs.get("top", 3)),
1671
+ }
1672
+ await self._run_tool_cmd("cloud_backtest", params,
1673
+ f"cloud backtest {len(symbols)} stocks")
1674
+ async def cmd_insights(self, args: str):
1675
+ """
1676
+ AI market insights for a basket of stocks.
1677
+ Usage: /insights sh600519 sh601318 sz000858
1678
+ """
1679
+ parts = args.strip().split() if args.strip() else ["sh600519"]
1680
+ symbols = parts
1681
+ await self._run_tool_cmd("get_market_insights",
1682
+ {"symbols": symbols},
1683
+ f"market insights {len(symbols)} stocks")
1684
+ def cmd_recommend(self, args: str):
1685
+ """Recommend best local models for financial analysis."""
1686
+ from aria_cli import RECOMMENDED_FINANCE_MODELS
1687
+ from aria_code.apps.cli.helpers import detect_ollama_models
1688
+ if self.context.has_rich:
1689
+ self.context.console.print()
1690
+ self.context.console.print(" [bold]Recommended Local Models for Finance[/bold]")
1691
+ self.context.console.print()
1692
+ try:
1693
+ available = detect_ollama_models(
1694
+ self.terminal.config.get("ollama_url", "http://localhost:11434")
1695
+ )
1696
+ for rec in RECOMMENDED_FINANCE_MODELS:
1697
+ model_id = rec["model"]
1698
+ installed = any(a.startswith(model_id.split(":")[0]) for a in available)
1699
+ icon = "[green]●[/green]" if installed else "[dim]○[/dim]"
1700
+ vram = rec.get("vram_gb", "?")
1701
+ self.context.console.print(
1702
+ f" {icon} [bold]{model_id:30s}[/bold] "
1703
+ f"[dim]VRAM≈{vram}GB {rec['reason'][:60]}[/dim]"
1704
+ )
1705
+ if not installed:
1706
+ self.context.console.print(f" [dim]Install: {rec['install']}[/dim]")
1707
+ self.context.console.print()
1708
+ except Exception:
1709
+ self.context.console.print(" [dim]Could not check installed models[/dim]")
1710
+ else:
1711
+ for rec in RECOMMENDED_FINANCE_MODELS:
1712
+ print(f" {rec['model']:30s} {rec['reason']}")
1713
+ print(f" Install: {rec['install']}")
1714
+ async def cmd_optimize_port(self, args: str):
1715
+ """Portfolio weight optimisation."""
1716
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1717
+ symbols = [s.strip().upper() for s in args.split() if s.strip()]
1718
+ if not symbols:
1719
+ self.context.console.print(" [dim]Usage: /optimize-port AAPL MSFT GOOGL [method=max_sharpe][/dim]" if self.context.has_rich
1720
+ else "Usage: /optimize-port AAPL MSFT [method=max_sharpe]")
1721
+ return
1722
+ # Check if last token is method=X
1723
+ method = "max_sharpe"
1724
+ if symbols and "=" in symbols[-1]:
1725
+ k, v = symbols.pop().split("=", 1)
1726
+ if k == "method":
1727
+ method = v
1728
+ params = {"symbols": symbols, "method": method}
1729
+ tool_name = "optimize_positions"
1730
+ if tool_name in LOCAL_TOOLS:
1731
+ await self._run_local_tool(tool_name, params, f"优化持仓 {method}")
1732
+ else:
1733
+ await self.terminal.send_message(
1734
+ f"帮我用 {method} 方法优化以下资产组合的仓位权重:{', '.join(symbols)}"
1735
+ )
1736
+ async def _run_local_tool(self, tool_name: str, params: dict, label: str = ""):
1737
+ """Run a LOCAL_TOOLS entry, display result with Rich formatting."""
1738
+ from aria_cli import _print_tool_result
1739
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOLS
1740
+ from aria_code.apps.cli.helpers import _clean_tool_error_message
1741
+ if tool_name not in LOCAL_TOOLS:
1742
+ if self.context.has_rich:
1743
+ self.context.console.print(f" [dim]Tool {tool_name!r} not available[/dim]")
1744
+ return
1745
+ handler, _ = LOCAL_TOOLS[tool_name]
1746
+ label_text = label or tool_name
1747
+ if self.context.has_rich:
1748
+ with self.context.console.status(f"[dim]{label_text}…[/dim]", spinner="dots"):
1749
+ result = handler(params)
1750
+ else:
1751
+ print(f" {label_text}…")
1752
+ result = handler(params)
1753
+
1754
+ if not result.get("success", True):
1755
+ err = _clean_tool_error_message(result.get("error", "unknown error"))
1756
+ if self.context.has_rich:
1757
+ self.context.console.print(f" [red]✗[/red] {err}")
1758
+ else:
1759
+ print(f" ✗ {err}")
1760
+ return
1761
+
1762
+ # Pretty-print result
1763
+ _print_tool_result(tool_name, result, elapsed=0)
1764
+ async def cmd_ui(self, args: str):
1765
+ """
1766
+ Generate a Bloomberg Terminal-style HTML file for any requested view.
1767
+ Usage: /ui <description>
1768
+ /ui 今日A股板块热力图
1769
+ /ui 持仓组合风险报告
1770
+ /ui 宏观利率与大宗商品看板
1771
+ """
1772
+ desc = args.strip()
1773
+ if not desc:
1774
+ if self.context.has_rich:
1775
+ self.context.console.print("[dim]Usage: /ui <描述>[/dim]")
1776
+ self.context.console.print("[dim] /ui 今日A股热力图[/dim]")
1777
+ self.context.console.print("[dim] /ui 持仓组合报告[/dim]")
1778
+ self.context.console.print("[dim] /ui 市场晨报看板[/dim]")
1779
+ else:
1780
+ print("Usage: /ui <description>")
1781
+ return
1782
+
1783
+ try:
1784
+ from apps.cli.prompts.ui import UI_SYSTEM_PROMPT
1785
+ except ImportError:
1786
+ UI_SYSTEM_PROMPT = ""
1787
+ try:
1788
+ from artifacts import user_generated_dir
1789
+ generated_dir = user_generated_dir()
1790
+ except Exception:
1791
+ generated_dir = pathlib.Path.home() / "Documents" / "Aria Code" / "generated"
1792
+
1793
+ prompt = (
1794
+ f"{UI_SYSTEM_PROMPT}\n\n"
1795
+ "---\n\n"
1796
+ f"USER REQUEST: {desc}\n\n"
1797
+ "Generate a Bloomberg Terminal-style HTML dashboard for this request.\n"
1798
+ "Follow the workflow:\n"
1799
+ "1. Identify what data you need (symbols, metrics, time range).\n"
1800
+ f"2. Write a Python generator script under {generated_dir} using write_file:\n"
1801
+ " - Fetch all data with yfinance / akshare\n"
1802
+ " - Embed data as JS/HTML constants\n"
1803
+ " - Apply the full Bloomberg CSS design system (dark/light via prefers-color-scheme)\n"
1804
+ f" - Save timestamped HTML under {generated_dir}\n"
1805
+ "3. Run the script with run_command.\n"
1806
+ "4. Open the output HTML in the browser.\n\n"
1807
+ "Design requirements (non-negotiable):\n"
1808
+ "- No emojis anywhere — use [+] [-] [!] [ACTIVE] text indicators\n"
1809
+ "- border-radius: 0 everywhere\n"
1810
+ "- IBM Plex Mono for all prices and numbers\n"
1811
+ "- prefers-color-scheme: dark default, light override\n"
1812
+ "- ALL CAPS section headers, 10px, letter-spacing 0.12em\n"
1813
+ "- Accent color: dark=#F5A623, light=#B8520A\n"
1814
+ )
1815
+
1816
+ if self.context.has_rich:
1817
+ self.context.console.print(f"[bold]UI Generation:[/bold] {desc}")
1818
+ else:
1819
+ print(f"Generating UI: {desc}")
1820
+
1821
+ await self.terminal.send_message(prompt)
1822
+ async def cmd_dashboard(self, args: str):
1823
+ """
1824
+ 生成个人化 Dashboard HTML,自动在浏览器打开。
1825
+ Usage: /dashboard [brief|market|portfolio|full]
1826
+ 数据来源: 本地持仓DB + 价格预警DB + yfinance 实时行情 + 最近生成文件
1827
+ """
1828
+ try:
1829
+ from dashboard_generator import generate_and_open
1830
+ except ImportError:
1831
+ if self.context.has_rich:
1832
+ self.context.console.print("[red]dashboard_generator.py 未找到,请检查安装[/red]")
1833
+ else:
1834
+ print("dashboard_generator.py 未找到")
1835
+ return
1836
+
1837
+ parts = args.strip().split()
1838
+ mode = parts[0].lower() if parts and parts[0].lower() in {"brief", "market", "portfolio", "full"} else "brief"
1839
+ watchlist = self.terminal.config.get("watchlist", [])
1840
+ if self.context.has_rich:
1841
+ self.context.console.print(f"[dim]正在抓取数据并生成 Dashboard({mode})…[/dim]")
1842
+ else:
1843
+ print(f"正在生成 Dashboard({mode})…")
1844
+
1845
+ try:
1846
+ out = generate_and_open(watchlist=watchlist, config=self.terminal.config, mode=mode)
1847
+ if self.context.has_rich:
1848
+ self.context.console.print(
1849
+ f" [green]✓[/green] Dashboard 已生成并在浏览器打开\n"
1850
+ f" [dim]路径: [bold]{out}[/bold][/dim]"
1851
+ )
1852
+ else:
1853
+ print(f"Dashboard saved: {out}")
1854
+ except Exception as exc:
1855
+ if self.context.has_rich:
1856
+ self.context.console.print(f"[red]生成失败: {exc}[/red]")
1857
+ else:
1858
+ print(f"生成失败: {exc}")
1859
+ async def cmd_tv(self, args: str):
1860
+ """Print a TradingView chart URL or export a Pine Script strategy."""
1861
+ from aria_cli import _build_tradingview_indicator_readout, _resolve_market_arg_symbol, _src_market_snapshot_analysis, _strip_latex, _write_text_companion, make_markdown
1862
+ from aria_code.apps.cli.helpers import _chart_display_label, _copy_text_to_clipboard, _open_path_or_url, _reveal_path_in_finder
1863
+ parts = [p.strip() for p in args.strip().split() if p.strip()]
1864
+ open_browser = any(p in {"--open", "-o", "open"} for p in parts)
1865
+ export_pine = any(p in {"--pine", "pine", "--strategy", "strategy", "策略"} for p in parts)
1866
+ copy_pine = any(p in {"--copy", "copy", "复制"} for p in parts)
1867
+ reveal_file = any(p in {"--reveal", "reveal", "finder", "访达", "目录"} for p in parts)
1868
+ txt_copy = any(p in {"--txt", "txt", "文本"} for p in parts)
1869
+ bullish_analysis = any(p in {"--bullish", "bullish", "看涨", "偏多"} for p in parts)
1870
+ bearish_analysis = any(p in {"--bearish", "bearish", "看跌", "偏空"} for p in parts)
1871
+ general_analysis = any(p in {"--analyze", "--analysis", "analyze", "analysis", "分析"} for p in parts)
1872
+ tv_analysis_mode = "bullish" if bullish_analysis else ("bearish" if bearish_analysis else ("analyze" if general_analysis else ""))
1873
+ interval = None
1874
+ symbol_parts = []
1875
+ skip_next = False
1876
+ for idx, part in enumerate(parts):
1877
+ if skip_next:
1878
+ skip_next = False
1879
+ continue
1880
+ low = part.lower()
1881
+ if low in {
1882
+ "--open", "-o", "open", "--pine", "pine", "--strategy", "strategy", "策略",
1883
+ "--copy", "copy", "复制", "--reveal", "reveal", "finder", "访达", "目录",
1884
+ "--txt", "txt", "文本", "--bullish", "bullish", "看涨", "偏多",
1885
+ "--bearish", "bearish", "看跌", "偏空", "--analyze", "--analysis",
1886
+ "analyze", "analysis", "分析",
1887
+ }:
1888
+ continue
1889
+ if low.startswith("--interval="):
1890
+ interval = part.split("=", 1)[1]
1891
+ continue
1892
+ if low == "--interval" and idx + 1 < len(parts):
1893
+ interval = parts[idx + 1]
1894
+ skip_next = True
1895
+ continue
1896
+ symbol_parts.append(part.strip(",,"))
1897
+
1898
+ raw_symbol = symbol_parts[0] if symbol_parts else "AAPL"
1899
+ symbol = _resolve_market_arg_symbol(raw_symbol)
1900
+ try:
1901
+ from apps.cli.tradingview_bridge import (
1902
+ export_pine_strategy,
1903
+ tradingview_symbol,
1904
+ tradingview_url,
1905
+ )
1906
+ tv_symbol = tradingview_symbol(symbol)
1907
+ url = tradingview_url(symbol, interval=interval)
1908
+ except Exception as exc:
1909
+ print_error(self.context, f"TradingView URL 生成失败: {exc}")
1910
+ return
1911
+
1912
+ if self.context.has_rich:
1913
+ self.context.console.print(f"\n [bold]TradingView[/bold] {_chart_display_label(raw_symbol, symbol)}")
1914
+ self.context.console.print(f" [dim]symbol:[/dim] {symbol} [dim]tv:[/dim] {tv_symbol}")
1915
+ self.context.console.print(f" [link={url}]{url}[/link]")
1916
+ else:
1917
+ print(f"TradingView {symbol} -> {tv_symbol}")
1918
+ print(url)
1919
+ self.terminal._pending_market_artifact = {
1920
+ "kind": "tradingview_chart",
1921
+ "symbol": symbol,
1922
+ "display": _chart_display_label(raw_symbol, symbol),
1923
+ "tv_symbol": tv_symbol,
1924
+ "url": url,
1925
+ "command": f"/tv {symbol}",
1926
+ }
1927
+
1928
+ if export_pine:
1929
+ try:
1930
+ pine_path = export_pine_strategy(symbol)
1931
+ self.terminal._pending_market_artifact = {
1932
+ "kind": "tradingview_pine_strategy",
1933
+ "symbol": symbol,
1934
+ "display": _chart_display_label(raw_symbol, symbol),
1935
+ "tv_symbol": tv_symbol,
1936
+ "url": url,
1937
+ "pine_path": str(pine_path),
1938
+ "path": str(pine_path),
1939
+ "command": f"/tv {symbol} --pine",
1940
+ }
1941
+ if self.context.has_rich:
1942
+ self.context.console.print(f" [green]✓[/green] Pine strategy saved: [link={pine_path}]{pine_path}[/link]")
1943
+ self.context.console.print(" [dim]Use: TradingView → Pine Editor → paste script → Save → Add to chart[/dim]")
1944
+ else:
1945
+ print(f"Pine strategy saved: {pine_path}")
1946
+ print("Use: TradingView -> Pine Editor -> paste script -> Save -> Add to chart")
1947
+ if txt_copy:
1948
+ companion = _write_text_companion(str(pine_path))
1949
+ if companion:
1950
+ if self.context.has_rich:
1951
+ self.context.console.print(f" [green]✓[/green] Text copy saved: [link={companion}]{companion}[/link]")
1952
+ else:
1953
+ print(f"Text copy saved: {companion}")
1954
+ if copy_pine:
1955
+ try:
1956
+ copied, copy_err = _copy_text_to_clipboard(pine_path.read_text(encoding="utf-8"))
1957
+ except Exception as exc:
1958
+ copied, copy_err = False, str(exc)
1959
+ if self.context.has_rich:
1960
+ self.context.console.print(" [green]✓[/green] Pine script copied to clipboard" if copied else f" [yellow]copy failed:[/yellow] {copy_err}")
1961
+ else:
1962
+ print("Pine script copied to clipboard" if copied else f"copy failed: {copy_err}")
1963
+ if reveal_file:
1964
+ revealed, reveal_err = _reveal_path_in_finder(str(pine_path))
1965
+ if self.context.has_rich:
1966
+ self.context.console.print(" [green]✓[/green] Revealed in Finder" if revealed else f" [yellow]reveal failed:[/yellow] {reveal_err}")
1967
+ else:
1968
+ print("Revealed in Finder" if revealed else f"reveal failed: {reveal_err}")
1969
+ except Exception as exc:
1970
+ print_error(self.context, f"Pine Script 导出失败: {exc}")
1971
+ return
1972
+
1973
+ if open_browser and url:
1974
+ opened, open_err = _open_path_or_url(url)
1975
+ if self.context.has_rich:
1976
+ self.context.console.print(" [green]✓[/green] opened in browser" if opened else f" [yellow]open failed:[/yellow] {open_err}")
1977
+ else:
1978
+ print("opened in browser" if opened else f"open failed: {open_err}")
1979
+
1980
+ if tv_analysis_mode:
1981
+ snapshot = _src_market_snapshot_analysis(f"分析 {symbol} 股票")
1982
+ if snapshot.get("success"):
1983
+ readout = _build_tradingview_indicator_readout(
1984
+ snapshot,
1985
+ tv_symbol=tv_symbol,
1986
+ mode=tv_analysis_mode,
1987
+ )
1988
+ if self.context.has_rich:
1989
+ self.context.console.print(make_markdown(_strip_latex(readout)))
1990
+ else:
1991
+ print(readout)
1992
+ self.terminal._last_response = readout
1993
+ else:
1994
+ msg = (
1995
+ f"TradingView 已打开,但当前无法生成 {symbol} 的指标分析:"
1996
+ f"{snapshot.get('error') or '行情数据不可用'}"
1997
+ )
1998
+ if self.context.has_rich:
1999
+ self.context.console.print(f" [yellow]{msg}[/yellow]")
2000
+ else:
2001
+ print(msg)
2002
+ async def cmd_chart(self, args: str):
2003
+ """
2004
+ 生成股票分析图表(HTML,含K线/均线/RSI/MACD)。
2005
+ Usage: /chart AAPL [period]
2006
+ /chart 600519 3m (A股,3个月)
2007
+ /chart BTC-USD 2y
2008
+ 支持 period: 1m 3m 6m 1y 2y 3y 5y ytd max
2009
+ """
2010
+ from aria_cli import _generate_chart_sync, _resolve_market_arg_symbol, sanitize_chart_symbol_args
2011
+ from aria_code.ui.render.output import display_path as _display_path
2012
+ from aria_code.apps.cli.helpers import _chart_display_label
2013
+ _VALID_PERIODS = {"1m","3m","6m","1y","2y","3y","5y","ytd","max",
2014
+ "1mo","3mo","6mo"}
2015
+ parts = args.strip().split()
2016
+ period = "1y"
2017
+ symbol_parts = []
2018
+ for p in parts:
2019
+ if p.lower() in _VALID_PERIODS:
2020
+ period = p.lower()
2021
+ else:
2022
+ symbol_parts.append(p.strip(",,"))
2023
+ raw_symbols = sanitize_chart_symbol_args(symbol_parts) or ["AAPL"]
2024
+ symbols = []
2025
+ seen_symbols = set()
2026
+ for raw_symbol in raw_symbols:
2027
+ resolved = _resolve_market_arg_symbol(raw_symbol)
2028
+ if resolved and resolved not in seen_symbols:
2029
+ seen_symbols.add(resolved)
2030
+ symbols.append((raw_symbol, resolved))
2031
+ if not symbols:
2032
+ symbols = [("AAPL", "AAPL")]
2033
+
2034
+ if len(symbols) > 1:
2035
+ await self._cmd_chart_multi(symbols, period)
2036
+ return
2037
+
2038
+ raw_symbol, symbol = symbols[0]
2039
+
2040
+ msg = f"chart {_chart_display_label(raw_symbol, symbol)} · {period}"
2041
+ chart_start = time.perf_counter()
2042
+ if self.context.has_rich:
2043
+ self.context.console.print(f"\n [bold]⏺[/bold] {msg}")
2044
+ with self.context.console.status("[dim]fetching OHLCV and calculating indicators…[/dim]", spinner="dots"):
2045
+ result = await asyncio.get_event_loop().run_in_executor(
2046
+ None, lambda: _generate_chart_sync(symbol, period=period)
2047
+ )
2048
+ else:
2049
+ print(f" > {msg}")
2050
+ result = await asyncio.get_event_loop().run_in_executor(
2051
+ None, lambda: _generate_chart_sync(symbol, period=period)
2052
+ )
2053
+ elapsed_ms = int((time.perf_counter() - chart_start) * 1000)
2054
+
2055
+ if result.get("success"):
2056
+ path = result.get("chart_path", "")
2057
+ path_label = _display_path(path, fallback="chart")
2058
+ issues = result.get("review_issues") or []
2059
+ sup3 = result.get("support") or []
2060
+ res3 = result.get("resistance") or []
2061
+ rsi_val = result.get("rsi")
2062
+ provider = result.get("provider") or "market data"
2063
+ display_label = _chart_display_label(raw_symbol, symbol, result)
2064
+ self.terminal._pending_market_artifact = {
2065
+ "kind": "stock_chart",
2066
+ "symbol": symbol,
2067
+ "display": display_label,
2068
+ "period": period,
2069
+ "html_path": path,
2070
+ "png_path": result.get("png_path") or "",
2071
+ "command": f"/chart {symbol} {period}",
2072
+ "provider": provider,
2073
+ "metrics": {
2074
+ "trend": result.get("trend"),
2075
+ "rsi": rsi_val,
2076
+ "support": sup3,
2077
+ "resistance": res3,
2078
+ },
2079
+ }
2080
+ if self.context.has_rich:
2081
+ self.context.console.print(f" [green]✓[/green] chart generated [dim]({elapsed_ms}ms)[/dim]")
2082
+ self.context.console.print(f" saved: [link={path}]{path_label}[/link]")
2083
+ self.context.console.print(
2084
+ f" [dim]{display_label} "
2085
+ f"趋势: {result.get('trend','—')} "
2086
+ f"RSI: {f'{rsi_val:.1f}' if rsi_val else '—'} "
2087
+ f"支撑: {'/'.join(str(v) for v in sup3) or '—'} "
2088
+ f"阻力: {'/'.join(str(v) for v in res3) or '—'} "
2089
+ f"数据: {provider}[/dim]"
2090
+ )
2091
+ if issues:
2092
+ self.context.console.print(f" [yellow]⚠ 自审发现 {len(issues)} 个问题:[/yellow]")
2093
+ for iss in issues:
2094
+ self.context.console.print(f" [yellow]· {iss}[/yellow]")
2095
+ else:
2096
+ self.context.console.print(" [green dim]✓ 自审通过(数据质量正常)[/green dim]")
2097
+ else:
2098
+ print(f" OK chart generated ({elapsed_ms}ms): {path_label}")
2099
+ print(f" {display_label} 趋势: {result.get('trend','—')} RSI: {f'{rsi_val:.1f}' if rsi_val else '—'} 数据: {provider}")
2100
+ if issues:
2101
+ print(f" ⚠ 自审发现 {len(issues)} 个问题:")
2102
+ for iss in issues:
2103
+ print(f" · {iss}")
2104
+ import subprocess as _sp
2105
+ try:
2106
+ _sp.Popen(["open", path])
2107
+ except Exception:
2108
+ pass
2109
+ else:
2110
+ err = result.get("error") or result.get("response", "未知错误")
2111
+ print_error(self.context, f"图表生成失败: {err[:120]}")
2112
+ async def _cmd_chart_multi(self, symbols: list[tuple[str, str]], period: str):
2113
+ """Generate a normalized comparison chart plus individual K-line charts."""
2114
+ from aria_code.ui.render.output import display_path as _display_path
2115
+ from aria_code.apps.cli.helpers import _chart_display_label
2116
+ labels = [_chart_display_label(raw, resolved) for raw, resolved in symbols]
2117
+ if self.context.has_rich:
2118
+ self.context.console.print(f"\n [bold]⏺[/bold] compare {' · '.join(labels)} · {period}")
2119
+ else:
2120
+ print(f" > compare {' '.join(labels)} · {period}")
2121
+
2122
+ compare_start = time.perf_counter()
2123
+ try:
2124
+ from apps.cli.handlers.chart_handlers import handle_multi_stock_comparison_direct as _multi_chart
2125
+ result = await asyncio.get_event_loop().run_in_executor(
2126
+ None, lambda: _multi_chart([resolved for _, resolved in symbols], period=period)
2127
+ )
2128
+ except Exception as exc:
2129
+ result = {"success": False, "error": str(exc)}
2130
+ elapsed_ms = int((time.perf_counter() - compare_start) * 1000)
2131
+
2132
+ if result.get("success"):
2133
+ path = result.get("chart_path", "")
2134
+ path_label = _display_path(path, fallback="comparison chart")
2135
+ self.terminal._pending_market_artifact = {
2136
+ "kind": "stock_comparison_chart",
2137
+ "symbol": " ".join(resolved for _, resolved in symbols),
2138
+ "display": " vs ".join(labels),
2139
+ "period": period,
2140
+ "html_path": path,
2141
+ "command": f"/chart {' '.join(resolved for _, resolved in symbols)} {period}",
2142
+ "metrics": result.get("metrics") or [],
2143
+ "children": [],
2144
+ }
2145
+ if self.context.has_rich:
2146
+ self.context.console.print(f" [green]✓[/green] comparison chart generated [dim]({elapsed_ms}ms)[/dim]")
2147
+ self.context.console.print(f" saved: [link={path}]{path_label}[/link]")
2148
+ rows = result.get("metrics") or []
2149
+ for row in rows[:6]:
2150
+ self.context.console.print(
2151
+ f" [dim]{row.get('symbol')}: "
2152
+ f"收益 {row.get('total_return_pct', 0):+.2f}% "
2153
+ f"波动 {row.get('volatility_pct', 0):.2f}% "
2154
+ f"最大回撤 {row.get('max_drawdown_pct', 0):.2f}%[/dim]"
2155
+ )
2156
+ else:
2157
+ print(f" OK comparison chart generated ({elapsed_ms}ms): {path_label}")
2158
+ else:
2159
+ print_error(self.context, f"对比图生成失败: {(result.get('error') or 'unknown')[:120]}")
2160
+
2161
+ child_artifacts = []
2162
+ for raw_symbol, resolved in symbols:
2163
+ await self.cmd_chart(f"{resolved} {period}")
2164
+ child = dict(getattr(self.terminal, "_pending_market_artifact", {}) or {})
2165
+ if child.get("html_path"):
2166
+ child_artifacts.append(child)
2167
+ if result.get("success"):
2168
+ self.terminal._pending_market_artifact = {
2169
+ "kind": "stock_comparison_chart",
2170
+ "symbol": " ".join(resolved for _, resolved in symbols),
2171
+ "display": " vs ".join(labels),
2172
+ "period": period,
2173
+ "html_path": result.get("chart_path", ""),
2174
+ "command": f"/chart {' '.join(resolved for _, resolved in symbols)} {period}",
2175
+ "metrics": result.get("metrics") or [],
2176
+ "children": child_artifacts,
2177
+ }
2178
+ async def cmd_shortterm(self, args: str):
2179
+ """
2180
+ 运行 A股短线分析(日线级别,3-15交易日)并输出报告。
2181
+ Usage: /shortterm
2182
+ /shortterm 000333 601138 300750
2183
+ """
2184
+
2185
+ import subprocess
2186
+ import sys as _sys
2187
+ _base = pathlib.Path(__file__).parent.parent.parent / "research" / "shortterm"
2188
+ script = _base / "run_shortterm.py"
2189
+ if not script.exists():
2190
+ print_error(self.context, f"短线分析脚本未找到: {script}")
2191
+ return
2192
+ codes = args.strip().split()
2193
+ cmd = [_sys.executable, str(script)]
2194
+ if codes:
2195
+ cmd += ["--code"] + codes
2196
+ if self.context.has_rich:
2197
+ self.context.console.print("\n 📊 运行短线分析...\n")
2198
+ else:
2199
+ print("\n 📊 运行短线分析...\n")
2200
+ result = subprocess.run(cmd, text=True, capture_output=False)
2201
+ if result.returncode != 0:
2202
+ print_error(self.context, "短线分析执行失败,请检查 research/shortterm/")
2203
+ async def cmd_longterm(self, args: str):
2204
+ """
2205
+ 运行 A股长线分析(月线级别,3-18个月目标)并输出报告。
2206
+ Usage: /longterm
2207
+ /longterm --quick (只分析 core 级标的)
2208
+ /longterm 600519 000858
2209
+ """
2210
+
2211
+ import subprocess
2212
+ import sys as _sys
2213
+ _base = pathlib.Path(__file__).parent.parent.parent / "research" / "longterm"
2214
+ script = _base / "run_longterm.py"
2215
+ if not script.exists():
2216
+ print_error(self.context, f"长线分析脚本未找到: {script}")
2217
+ return
2218
+ parts = args.strip().split()
2219
+ cmd = [_sys.executable, str(script)]
2220
+ if "--quick" in parts:
2221
+ cmd.append("--quick")
2222
+ parts.remove("--quick")
2223
+ if parts:
2224
+ cmd += ["--code"] + parts
2225
+ if self.context.has_rich:
2226
+ self.context.console.print("\n 📈 运行长线分析...\n")
2227
+ else:
2228
+ print("\n 📈 运行长线分析...\n")
2229
+ result = subprocess.run(cmd, text=True, capture_output=False)
2230
+ if result.returncode != 0:
2231
+ print_error(self.context, "长线分析执行失败,请检查 research/longterm/")
2232
+ async def cmd_indices(self, args: str):
2233
+ """全球主要指数实时行情."""
2234
+ from aria_code.apps.cli._optional import HAS_MDC as _HAS_MDC, get_mdc as _get_mdc
2235
+ from aria_code.apps.cli.helpers import _clean_tool_error_message
2236
+ if not _HAS_MDC:
2237
+ self.context.console.print(" [dim]market_data_client 未加载[/dim]" if self.context.has_rich else "market_data_client not loaded")
2238
+ return
2239
+ mdc = _get_mdc()
2240
+ if self.context.has_rich:
2241
+ with self.context.console.status("[dim]获取全球指数...[/dim]", spinner="dots"):
2242
+ r = mdc.indices()
2243
+ else:
2244
+ print(" 获取全球指数...")
2245
+ r = mdc.indices()
2246
+
2247
+ if not r.get("success"):
2248
+ err = _clean_tool_error_message(r.get("error", "failed"))
2249
+ self.context.console.print(f" [red]{err}[/red]" if self.context.has_rich else err)
2250
+ return
2251
+
2252
+ if self.context.has_rich:
2253
+ self.context.console.print()
2254
+ self.context.console.print(" [bold]全球指数行情[/bold] "
2255
+ f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim]")
2256
+ self.context.console.print()
2257
+ for name, d in r["indices"].items():
2258
+ chg = d.get("change_pct", 0)
2259
+ color = "green" if chg >= 0 else "red"
2260
+ sign = "+" if chg >= 0 else ""
2261
+ self.context.console.print(
2262
+ f" [bold]{name:<14s}[/bold]"
2263
+ f" {str(d.get('price','')):<12}"
2264
+ f" [{color}]{sign}{chg:.2f}%[/{color}]"
2265
+ )
2266
+ self.context.console.print()
2267
+ else:
2268
+ for name, d in r["indices"].items():
2269
+ chg = d.get("change_pct", 0)
2270
+ sign = "+" if chg >= 0 else ""
2271
+ print(f" {name:<16} {str(d.get('price','')):<12} {sign}{chg:.2f}%")
2272
+ async def cmd_hot(self, args: str):
2273
+ """热门/活跃股票榜单. Usage: /hot [cn|us] [top=20]"""
2274
+ from aria_code.apps.cli._optional import HAS_MDC as _HAS_MDC, get_mdc as _get_mdc
2275
+ if not _HAS_MDC:
2276
+ self.context.console.print(" [dim]market_data_client 未加载[/dim]" if self.context.has_rich else "market_data_client not loaded")
2277
+ return
2278
+ parts = args.strip().lower().split()
2279
+ market = "us" if "us" in parts else "cn"
2280
+ top_n = 20
2281
+ for p in parts:
2282
+ if p.startswith("top="):
2283
+ try: top_n = int(p.split("=")[1])
2284
+ except ValueError: pass
2285
+
2286
+ mdc = _get_mdc()
2287
+ if self.context.has_rich:
2288
+ with self.context.console.status(f"[dim]获取{market.upper()}热门股...[/dim]", spinner="dots"):
2289
+ r = mdc.hot_stocks(market=market, top_n=top_n)
2290
+ else:
2291
+ r = mdc.hot_stocks(market=market, top_n=top_n)
2292
+
2293
+ if not r.get("success"):
2294
+ self.context.console.print(f" [red]{r.get('error','failed')}[/red]" if self.context.has_rich else r.get('error'))
2295
+ return
2296
+
2297
+ stocks = r.get("stocks", [])
2298
+ if self.context.has_rich:
2299
+ self.context.console.print()
2300
+ self.context.console.print(f" [bold]{market.upper()} 热门股 Top {len(stocks)}[/bold] "
2301
+ f"[dim]provider: {r.get('provider','')}[/dim]")
2302
+ self.context.console.print()
2303
+ for i, s in enumerate(stocks, 1):
2304
+ sym = s.get("code") or s.get("symbol","")
2305
+ name = s.get("name", sym)
2306
+ p = s.get("price", "-")
2307
+ chg = s.get("change_pct", 0)
2308
+ color = "green" if chg >= 0 else "red"
2309
+ sign = "+" if chg >= 0 else ""
2310
+ self.context.console.print(
2311
+ f" [dim]{i:2d}.[/dim] [bold]{name:<8s}[/bold] "
2312
+ f"[dim]{sym:<8s}[/dim] {str(p):<8} "
2313
+ f"[{color}]{sign}{chg:.2f}%[/{color}]"
2314
+ )
2315
+ self.context.console.print()
2316
+ else:
2317
+ for s in stocks:
2318
+ sym = s.get("code") or s.get("symbol","")
2319
+ print(f" {s.get('name',sym):<10} {sym:<8} {s.get('price','-'):<8} {s.get('change_pct',0):+.2f}%")
2320
+ async def cmd_ta(self, args: str):
2321
+ """技术指标分析. Usage: /ta NVDA [days=120]"""
2322
+ from aria_cli import _generate_chart_sync, parse_technical_args, print_ta_result
2323
+ from aria_code.ui.render.output import display_path as _display_path
2324
+ from aria_code.apps.cli.helpers import _chart_period_from_ta_days, _display_value
2325
+ parsed = parse_technical_args(args)
2326
+ symbol = parsed.symbol
2327
+ days = parsed.days
2328
+
2329
+ service_result = None
2330
+ if self.context.has_rich:
2331
+ with self.context.console.status(f"[dim]计算 {symbol} 技术指标...[/dim]", spinner="dots"):
2332
+ from packages.aria_services.data import DataService
2333
+ service_result = DataService().technical_indicators(symbol, days=days)
2334
+ else:
2335
+ from packages.aria_services.data import DataService
2336
+ service_result = DataService().technical_indicators(symbol, days=days)
2337
+ if not service_result or not service_result.success:
2338
+ _ta_warns = (service_result.warnings or []) if service_result else []
2339
+ _ta_errs = (service_result.errors or []) if service_result else []
2340
+ _ta_data = (service_result.data or {}) if service_result else {}
2341
+ _missing = ", ".join(service_result.missing_fields) if service_result else ""
2342
+ _all_msgs = " ".join(_ta_warns + _ta_errs).lower()
2343
+ # Show current price when we have partial data (e.g. new IPO with 1 bar)
2344
+ _price_line = ""
2345
+ if _ta_data.get("price"):
2346
+ _price_line = f" 当前价格 [bold]{_display_value(_ta_data['price'])}[/bold]"
2347
+ if _ta_data.get("history_bars"):
2348
+ _price_line += f" [dim]({_ta_data['history_bars']} 个交易日数据)[/dim]"
2349
+ _price_line += "\n"
2350
+ if "数据不足" in _all_msgs or "新上市" in _all_msgs:
2351
+ _reason = f"[yellow]历史数据不足[/yellow] — {symbol} 上市时间较短(< 14 个交易日),TA 指标无法计算\n [dim]可待更多交易日积累后重试,或运行 `/analyze {symbol}` 查看基本面[/dim]"
2352
+ elif "rate" in _all_msgs or "429" in _all_msgs or "too many" in _all_msgs:
2353
+ _reason = "[yellow]数据源频率限制[/yellow] — 稍后重试,或用 `/apikey set finnhub <KEY>` 切换数据源"
2354
+ else:
2355
+ _err = "; ".join(_ta_errs or _ta_warns) or "数据源暂时不可用"
2356
+ _reason = f"[red]{_err[:120]}[/red]"
2357
+ if _missing:
2358
+ _reason += f" [dim]missing: {_missing}[/dim]"
2359
+ if self.context.has_rich:
2360
+ if _price_line:
2361
+ self.context.console.print(f"\n{_price_line}")
2362
+ self.context.console.print(f" {_reason}\n")
2363
+ else:
2364
+ import re as _re
2365
+ print(f"\n {_re.sub(r'[[/].*?]', '', _price_line + _reason)}\n")
2366
+ return
2367
+
2368
+ print_ta_result(
2369
+ console=self.context.console,
2370
+ has_rich=self.context.has_rich,
2371
+ symbol=symbol,
2372
+ days=days,
2373
+ service_result=service_result,
2374
+ formatter=_display_value,
2375
+ )
2376
+
2377
+ period = _chart_period_from_ta_days(days)
2378
+ self.terminal._pending_market_artifact = {
2379
+ "kind": "ta_chart",
2380
+ "symbol": symbol,
2381
+ "period": period,
2382
+ "command": f"/chart {symbol} {period}",
2383
+ }
2384
+ chart_result = None
2385
+ if self.context.has_rich:
2386
+ with self.context.console.status(f"[dim]生成 {symbol} 技术图表 HTML/PNG...[/dim]", spinner="dots"):
2387
+ chart_result = await asyncio.get_event_loop().run_in_executor(
2388
+ None, lambda: _generate_chart_sync(symbol, period=period)
2389
+ )
2390
+ else:
2391
+ print(f" 生成 {symbol} 技术图表 HTML/PNG...")
2392
+ chart_result = await asyncio.get_event_loop().run_in_executor(
2393
+ None, lambda: _generate_chart_sync(symbol, period=period)
2394
+ )
2395
+
2396
+ if chart_result and chart_result.get("success"):
2397
+ html_path = chart_result.get("chart_path") or ""
2398
+ png_path = chart_result.get("png_path") or ""
2399
+ png_error = chart_result.get("png_error") or ""
2400
+ self.terminal._pending_market_artifact = {
2401
+ "kind": "ta_chart",
2402
+ "symbol": symbol,
2403
+ "period": period,
2404
+ "html_path": html_path,
2405
+ "png_path": png_path,
2406
+ "command": f"/chart {symbol} {period}",
2407
+ }
2408
+ if self.context.has_rich:
2409
+ self.context.console.print()
2410
+ self.context.console.print(" [green]✓[/green] 技术图表已生成")
2411
+ if html_path:
2412
+ self.context.console.print(f" [dim]HTML:[/dim] [link={html_path}]{_display_path(html_path)}[/link]")
2413
+ if png_path:
2414
+ self.context.console.print(f" [dim]PNG :[/dim] [link={png_path}]{_display_path(png_path)}[/link]")
2415
+ elif png_error:
2416
+ self.context.console.print(f" [yellow]PNG 跳过:[/yellow] {png_error[:90]}")
2417
+ else:
2418
+ print("\n 技术图表已生成")
2419
+ if html_path:
2420
+ print(f" HTML: {_display_path(html_path)}")
2421
+ if png_path:
2422
+ print(f" PNG : {_display_path(png_path)}")
2423
+ elif png_error:
2424
+ print(f" PNG 跳过: {png_error[:90]}")
2425
+ elif chart_result:
2426
+ err = chart_result.get("error") or "图表生成失败"
2427
+ if self.context.has_rich:
2428
+ self.context.console.print(f" [yellow]图表生成跳过:[/yellow] {err[:120]}")
2429
+ else:
2430
+ print(f" 图表生成跳过: {err[:120]}")
2431
+ def _extract_last_code(self) -> str:
2432
+ """从对话历史中提取最后一段 Python 代码块."""
2433
+ import re
2434
+ for msg in reversed(self.terminal.conversation):
2435
+ content = msg.get("content", "")
2436
+ # Match ```python ... ``` blocks
2437
+ matches = re.findall(r"```(?:python)?\n(.*?)```", content, re.DOTALL)
2438
+ if matches:
2439
+ # Return the longest code block
2440
+ return max(matches, key=len)
2441
+ return ""
2442
+ async def cmd_orcl(self, args: str):
2443
+ """Oracle Corporation (ORCL) analysis."""
2444
+ deep = "deep" in args.lower()
2445
+ if deep:
2446
+ prompt = (
2447
+ "Perform a comprehensive multi-factor analysis of Oracle Corporation (ORCL):\n"
2448
+ "1. Technical: trend, RSI, MACD, key support/resistance levels\n"
2449
+ "2. Fundamental: revenue growth, cloud transition progress, margins, PE vs peers (MSFT, SAP, NOW)\n"
2450
+ "3. Competitive: OCI vs AWS/Azure/GCP market share, Autonomous DB moat\n"
2451
+ "4. AI angle: Oracle's AI infrastructure deals (NVIDIA partnership, xAI, OpenAI cloud)\n"
2452
+ "5. Risks: debt load from cloud capex, Cerner integration, FX exposure\n"
2453
+ "6. Verdict: Bull/Bear/Neutral with price target and conviction level"
2454
+ )
2455
+ else:
2456
+ prompt = (
2457
+ "Give me a quick snapshot of Oracle (ORCL):\n"
2458
+ "1. Current price, YTD performance vs S&P500\n"
2459
+ "2. Key metrics: PE, forward PE, revenue growth, cloud ARR\n"
2460
+ "3. Recent news and catalysts\n"
2461
+ "4. Technical signal: Buy/Hold/Sell\n"
2462
+ "5. One-line thesis"
2463
+ )
2464
+ await self.terminal._handle_ai_message(prompt)
2465
+ async def cmd_edgar(self, args: str):
2466
+ """
2467
+ SEC EDGAR 美国上市公司财报与披露查询(完全免费)。
2468
+ Usage: /edgar AAPL — 最近财报列表
2469
+ /edgar MSFT filings — 10-K/10-Q 提交记录
2470
+ /edgar TSLA facts — 财务事实(收入/利润历史)
2471
+ /edgar NVDA insider — 内幕交易披露 (Form 4)
2472
+ """
2473
+ from aria_cli import _fetch_edgar_data
2474
+ parts = args.strip().split()
2475
+ if not parts:
2476
+ self.context.console.print(" [dim]Usage: /edgar SYMBOL [filings|facts|insider][/dim]" if self.context.has_rich
2477
+ else "Usage: /edgar SYMBOL [filings|facts|insider]")
2478
+ return
2479
+
2480
+ symbol = parts[0].upper()
2481
+ sub = parts[1].lower() if len(parts) > 1 else "filings"
2482
+
2483
+ if self.context.has_rich:
2484
+ with self.context.console.status(f"[dim]查询 EDGAR {symbol}...[/dim]", spinner="dots"):
2485
+ result = await asyncio.get_event_loop().run_in_executor(
2486
+ None, lambda: _fetch_edgar_data(symbol, sub)
2487
+ )
2488
+ else:
2489
+ result = await asyncio.get_event_loop().run_in_executor(
2490
+ None, lambda: _fetch_edgar_data(symbol, sub)
2491
+ )
2492
+
2493
+ if not result:
2494
+ print_error(self.context, f"未找到 {symbol} 的 EDGAR 数据")
2495
+ return
2496
+
2497
+ if self.context.has_rich:
2498
+ from rich.table import Table
2499
+ from rich import box as rich_box
2500
+ if sub == "filings":
2501
+ table = Table(title=f"[bold]{symbol}[/bold] SEC 财报提交",
2502
+ box=rich_box.SIMPLE, header_style="bold dim")
2503
+ table.add_column("类型", width=6)
2504
+ table.add_column("日期", width=12)
2505
+ table.add_column("链接", style="dim")
2506
+ for f in result[:10]:
2507
+ table.add_row(f.get("form",""), f.get("date",""), f.get("url","")[:60])
2508
+ self.context.console.print(table)
2509
+ elif sub == "facts":
2510
+ self.context.console.print(f" [bold]{symbol}[/bold] 财务摘要:")
2511
+ for metric, entries in result.get("metrics", {}).items():
2512
+ if entries:
2513
+ latest = entries[0]
2514
+ self.context.console.print(f" [dim]{metric}[/dim] {latest.get('val',0):,.0f} ({latest.get('end','')})")
2515
+ elif sub == "insider":
2516
+ self.context.console.print(f" [bold]{symbol}[/bold] 近期内幕交易 ({len(result)} 条):")
2517
+ for f in result[:10]:
2518
+ self.context.console.print(f" [dim]{f.get('date','')}[/dim] Form 4")
2519
+ else:
2520
+ print(f" {symbol} EDGAR 数据: {len(result) if isinstance(result, list) else 'OK'}")