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,2543 @@
1
+ """BacktestCommandsMixin — backtest/strategy/scaffold commands.
2
+
3
+ Extracted from aria_cli.py. Module globals imported lazily inside method bodies.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from ._ui import has_rich, print_error
9
+
10
+
11
+ def format_backtest_data_error(
12
+ symbol: str,
13
+ *,
14
+ start_date: str,
15
+ end_date: str,
16
+ local_error: str = "",
17
+ bars: int = 0,
18
+ ) -> str:
19
+ """Return a user-facing backtest failure message."""
20
+ if bars and bars < 5:
21
+ return (
22
+ f"{symbol} 历史数据仅 {bars} 个交易日,不足以回测。"
23
+ "请换历史更长的标的或缩短策略周期。"
24
+ )
25
+ if local_error:
26
+ low = local_error.lower()
27
+ if "histor" in low or "data" in low or "empty" in low:
28
+ return (
29
+ f"{symbol} 回测失败:{local_error}。"
30
+ "请检查数据源是否可用、ticker 是否正确,或先运行 /doctor /health。"
31
+ )
32
+ return (
33
+ f"{symbol} 在 {start_date} → {end_date} 范围内没有可用历史数据。"
34
+ "请检查代码是否正确、标的是否已上市/未停牌,或缩短回测区间。"
35
+ )
36
+
37
+
38
+ def _bt_num(value, default: float = 0.0) -> float:
39
+ try:
40
+ return float(value)
41
+ except Exception:
42
+ return default
43
+
44
+
45
+ def _bt_pct(value, digits: int = 1, signed: bool = False) -> str:
46
+ number = _bt_num(value)
47
+ sign = "+" if signed and number >= 0 else ""
48
+ return f"{sign}{number * 100:.{digits}f}%"
49
+
50
+
51
+ def _bt_money(value, currency: str = "USD") -> str:
52
+ number = _bt_num(value)
53
+ return f"{currency} {number:,.0f}" if abs(number) >= 1000 else f"{currency} {number:,.2f}"
54
+
55
+
56
+ def _bt_int(value, default: int = 0) -> int:
57
+ try:
58
+ return int(value)
59
+ except Exception:
60
+ return default
61
+
62
+
63
+ def _bt_trade_count(data: dict) -> int:
64
+ for key in ("total_trades", "num_trades", "n_trades", "trades"):
65
+ if key in data and data.get(key) is not None:
66
+ return _bt_int(data.get(key))
67
+ return 0
68
+
69
+
70
+ def _bt_value(data: dict, *keys, default=None):
71
+ for key in keys:
72
+ if key in data and data.get(key) is not None:
73
+ return data.get(key)
74
+ return default
75
+
76
+
77
+ def _bt_volume_summary(data: dict) -> dict:
78
+ summary = data.get("volume_summary")
79
+ return summary if isinstance(summary, dict) else {}
80
+
81
+
82
+ def _bt_result_summary(data: dict) -> str:
83
+ total = _bt_num(data.get("total_return"))
84
+ benchmark = _bt_num(_bt_value(data, "buy_hold_return", "benchmark_return", default=0))
85
+ sharpe = _bt_num(data.get("sharpe_ratio"))
86
+ max_dd = _bt_num(data.get("max_drawdown"))
87
+ relation = "高于" if total > benchmark else "低于" if total < benchmark else "持平"
88
+ return (
89
+ f"结论:策略收益 {_bt_pct(total)},{relation}买入持有 {_bt_pct(benchmark)};"
90
+ f"Sharpe {sharpe:.2f},最大回撤 {_bt_pct(max_dd)}。"
91
+ )
92
+
93
+
94
+ import logging
95
+ import json
96
+ import asyncio
97
+ import datetime
98
+ import time
99
+ import shlex
100
+ import sys
101
+ import os
102
+ from typing import Dict, Any, Optional
103
+
104
+ def format_sparkline(*args, **kwargs):
105
+ from aria_code.apps.cli.helpers import format_sparkline as fn
106
+ return fn(*args, **kwargs)
107
+ def _get_broker_registry(*args, **kwargs):
108
+ from .._optional import get_registry as fn
109
+ return fn(*args, **kwargs)
110
+ def _get__HAS_BROKERS():
111
+ from .._optional import HAS_BROKERS as val
112
+ return val
113
+ def _tool_run_command(*args, **kwargs):
114
+ from aria_cli import _tool_run_command as fn
115
+ return fn(*args, **kwargs)
116
+ def _get__HAS_VAULT():
117
+ from aria_cli import _HAS_VAULT as val
118
+ return val
119
+ import pandas as pd
120
+ def _get__HAS_MDC():
121
+ from .._optional import HAS_MDC as val
122
+ return val
123
+ import pathlib
124
+ def _get_vault(*args, **kwargs):
125
+ from aria_cli import _get_vault as fn
126
+ return fn(*args, **kwargs)
127
+ def _tool_write_file(*args, **kwargs):
128
+ from aria_cli import _tool_write_file as fn
129
+ return fn(*args, **kwargs)
130
+ def _ai_review(*args, **kwargs):
131
+ from aria_cli import _ai_review as fn
132
+ return fn(*args, **kwargs)
133
+ def _get_mdc(*args, **kwargs):
134
+ from .._optional import get_mdc as fn
135
+ return fn(*args, **kwargs)
136
+
137
+ import json
138
+ import asyncio
139
+ import datetime
140
+ import time
141
+ import shlex
142
+ import sys
143
+ import os
144
+ from typing import Dict, Any, Optional
145
+
146
+ # A real logger. This was a `def logger(*args, **kwargs)` forwarding to
147
+ # aria_cli, so every `logger.debug(...)` raised AttributeError — and all but
148
+ # one of them sit in an except block, where the failure replaced whatever
149
+ # error was being reported.
150
+ logger = logging.getLogger(__name__)
151
+
152
+
153
+ class BacktestCommandsMixin:
154
+ """Mixin providing backtest, strategy, factor-lab, and scaffold commands."""
155
+
156
+ _bt_num = staticmethod(_bt_num)
157
+ _bt_pct = staticmethod(_bt_pct)
158
+ _bt_money = staticmethod(_bt_money)
159
+ _bt_int = staticmethod(_bt_int)
160
+ _bt_trade_count = staticmethod(_bt_trade_count)
161
+ _bt_value = staticmethod(_bt_value)
162
+ _bt_volume_summary = staticmethod(_bt_volume_summary)
163
+ _bt_result_summary = staticmethod(_bt_result_summary)
164
+
165
+ async def cmd_backtest(self, args: str):
166
+ """Direct REST backtest → /api/v1/backtest (falls back to Aria tool).
167
+
168
+ Usage:
169
+ /backtest [strategy] [symbol] [start_date] [end_date]
170
+ /backtest momentum AAPL 2023-01-01 2024-12-31
171
+ /backtest momentum AAPL --period 1y
172
+ /backtest momentum AAPL --period 6m
173
+ """
174
+
175
+ import re as _re_bt
176
+ today = __import__("datetime").date.today()
177
+
178
+ raw_parts = args.split() if args else ["momentum", "SPY"]
179
+
180
+ # Handle flags (e.g. --period 1y, --fast 20, --slow 60, --symbol AAPL)
181
+ _period_match = None
182
+ _symbol_flag = None
183
+ _fast_period = 20
184
+ _slow_period = 60
185
+ _momentum_period = 20
186
+ _initial_capital = 100000
187
+ _output_dir = None
188
+ _cleaned = []
189
+ i = 0
190
+ while i < len(raw_parts):
191
+ if raw_parts[i] == "--period" and i + 1 < len(raw_parts):
192
+ _period_match = raw_parts[i + 1]
193
+ i += 2
194
+ elif raw_parts[i].startswith("--period="):
195
+ _period_match = raw_parts[i].split("=", 1)[1]
196
+ i += 1
197
+ elif raw_parts[i] == "--symbol" and i + 1 < len(raw_parts):
198
+ _symbol_flag = raw_parts[i + 1].upper()
199
+ i += 2
200
+ elif raw_parts[i].startswith("--symbol="):
201
+ _symbol_flag = raw_parts[i].split("=", 1)[1].upper()
202
+ i += 1
203
+ elif raw_parts[i] == "--fast" and i + 1 < len(raw_parts):
204
+ try:
205
+ _fast_period = int(raw_parts[i + 1])
206
+ except Exception:
207
+ pass
208
+ i += 2
209
+ elif raw_parts[i].startswith("--fast="):
210
+ try:
211
+ _fast_period = int(raw_parts[i].split("=", 1)[1])
212
+ except Exception:
213
+ pass
214
+ i += 1
215
+ elif raw_parts[i] == "--slow" and i + 1 < len(raw_parts):
216
+ try:
217
+ _slow_period = int(raw_parts[i + 1])
218
+ except Exception:
219
+ pass
220
+ i += 2
221
+ elif raw_parts[i].startswith("--slow="):
222
+ try:
223
+ _slow_period = int(raw_parts[i].split("=", 1)[1])
224
+ except Exception:
225
+ pass
226
+ i += 1
227
+ elif raw_parts[i] == "--momentum" and i + 1 < len(raw_parts):
228
+ try:
229
+ _momentum_period = int(raw_parts[i + 1])
230
+ except Exception:
231
+ pass
232
+ i += 2
233
+ elif raw_parts[i].startswith("--momentum="):
234
+ try:
235
+ _momentum_period = int(raw_parts[i].split("=", 1)[1])
236
+ except Exception:
237
+ pass
238
+ i += 1
239
+ elif raw_parts[i] == "--capital" and i + 1 < len(raw_parts):
240
+ try:
241
+ _initial_capital = float(raw_parts[i + 1])
242
+ except Exception:
243
+ pass
244
+ i += 2
245
+ elif raw_parts[i].startswith("--capital="):
246
+ try:
247
+ _initial_capital = float(raw_parts[i].split("=", 1)[1])
248
+ except Exception:
249
+ pass
250
+ i += 1
251
+ elif raw_parts[i] == "--output" and i + 1 < len(raw_parts):
252
+ _output_dir = raw_parts[i + 1]
253
+ i += 2
254
+ elif raw_parts[i].startswith("--output="):
255
+ _output_dir = raw_parts[i].split("=", 1)[1]
256
+ i += 1
257
+ else:
258
+ _cleaned.append(raw_parts[i])
259
+ i += 1
260
+ parts = _cleaned
261
+
262
+ # Resolve --period to a start date
263
+ if _period_match:
264
+ _pm = _period_match.lower()
265
+ _months = {"1m": 1, "3m": 3, "6m": 6, "1y": 12, "2y": 24, "3y": 36, "5y": 60}
266
+ if _pm in _months:
267
+ from datetime import timedelta
268
+ _delta_days = _months[_pm] * 30
269
+ _start_dt = today - timedelta(days=_delta_days)
270
+ _resolved_start = _start_dt.isoformat()
271
+ else:
272
+ _resolved_start = None
273
+ else:
274
+ _resolved_start = None
275
+
276
+ _known_strategies = {"momentum", "mom", "sma_cross", "ma_cross", "moving_average",
277
+ "buy_hold", "buyhold", "hold", "ml", "ml_signal", "agent"}
278
+ if len(parts) == 1 and parts[0].lower() not in _known_strategies:
279
+ strategy = "momentum"
280
+ symbol = parts[0].upper()
281
+ else:
282
+ strategy = parts[0] if len(parts) > 0 else "momentum"
283
+ symbol = parts[1].upper() if len(parts) > 1 else "SPY"
284
+ if _symbol_flag:
285
+ symbol = _symbol_flag
286
+
287
+ # Positional start/end dates only accepted if they look like YYYY-MM-DD
288
+ #
289
+ # 2026-08-19 修复 UnboundLocalError:这段原本在 ML / Agent 两个分支
290
+ # **之后**(第 240 行),但那两个分支在第 226/233 行就把 start_date /
291
+ # end_date 当关键字参数传了出去。也就是说 `/backtest ml ...` 和
292
+ # `/backtest agent ...` 这两条路径必然抛 UnboundLocalError——只有走到
293
+ # 后面通用分支的调用才碰巧能工作。
294
+ # mypy 的 used-before-def 一次就报了出来(4 处),AST 独立复核确认:
295
+ # start_date 首次赋值在第 240 行,而使用出现在 226、233。
296
+ _date_re = _re_bt.compile(r'^\d{4}-\d{2}-\d{2}$')
297
+ _raw_start = parts[2] if len(parts) > 2 else None
298
+ _raw_end = parts[3] if len(parts) > 3 else None
299
+ start_date = (_raw_start if _raw_start and _date_re.match(_raw_start) else None) \
300
+ or _resolved_start or "2023-01-01"
301
+ end_date = (_raw_end if _raw_end and _date_re.match(_raw_end) else None) \
302
+ or today.isoformat()
303
+
304
+ # ── ML 信号组合回测 ──────────────────────────────────────────────────
305
+ if strategy.lower() in ("ml", "ml_signal"):
306
+ await self._cmd_ml_signal_backtest(parts[1:], start_date=start_date,
307
+ end_date=end_date,
308
+ capital=_initial_capital)
309
+ return
310
+
311
+ # ── Agent 信号回测 ──────────────────────────────────────────────────
312
+ if strategy.lower() == "agent":
313
+ await self._cmd_agent_backtest(symbol, start_date=start_date, end_date=end_date, capital=_initial_capital)
314
+ return
315
+
316
+ label = f"Backtesting {strategy} on {symbol} ({start_date}→{end_date})"
317
+ api_url = self.terminal.config.get("api_url", "http://localhost:8000")
318
+
319
+ async def _do_backtest():
320
+ from backtest_report import BacktestConfig, generate_backtest_report
321
+ local_config = BacktestConfig(
322
+ symbol=symbol,
323
+ strategy=strategy,
324
+ start_date=start_date,
325
+ end_date=end_date,
326
+ initial_capital=float(_initial_capital),
327
+ fast_period=int(_fast_period),
328
+ slow_period=int(_slow_period),
329
+ momentum_period=int(_momentum_period),
330
+ )
331
+ local_error = ""
332
+ try:
333
+ _out_path = None
334
+ if _output_dir:
335
+ _out_path = pathlib.Path(_output_dir).expanduser()
336
+ if not _out_path.is_absolute():
337
+ from artifacts import user_generated_dir
338
+ _out_path = user_generated_dir() / _out_path
339
+ local_result = await asyncio.get_event_loop().run_in_executor(
340
+ None, lambda: generate_backtest_report(local_config, output_dir=_out_path)
341
+ )
342
+ if local_result and local_result.get("success"):
343
+ return {"success": True, "data": local_result, "_source": "local-real-data"}
344
+ local_error = local_result.get("error") if local_result else ""
345
+ logger.debug("local backtest failed, falling back to yfinance direct: %s", local_error or "None")
346
+ except Exception as _e:
347
+ local_error = str(_e)
348
+ logger.debug("local backtest failed, falling back to yfinance direct: %s", _e)
349
+
350
+ # ── Direct yfinance backtest — works offline, no backend needed ──
351
+ try:
352
+ import yfinance as _yf
353
+ import numpy as _np
354
+ import statistics as _stats
355
+
356
+ _yf_bars = [0] # records bars found, for a precise error message
357
+
358
+ def _run_yf_backtest():
359
+ _ticker = _yf.Ticker(symbol)
360
+ _df = _ticker.history(start=start_date, end=end_date, auto_adjust=True)
361
+ if _df is None or _df.empty:
362
+ return {"success": False, "error": format_backtest_data_error(
363
+ symbol,
364
+ start_date=start_date,
365
+ end_date=end_date,
366
+ bars=0,
367
+ ), "bars": 0}
368
+ _close = _df["Close"].dropna()
369
+ _yf_bars[0] = len(_close)
370
+ if len(_close) < 5:
371
+ return {"success": False, "error": format_backtest_data_error(
372
+ symbol,
373
+ start_date=start_date,
374
+ end_date=end_date,
375
+ bars=_yf_bars[0],
376
+ ), "bars": _yf_bars[0]}
377
+ _prices = list(_close)
378
+ n = len(_prices)
379
+ # Momentum strategy: buy when N-day momentum > 0
380
+ _mp = int(_momentum_period)
381
+ _signals = [0] * n
382
+ for i in range(_mp, n):
383
+ _signals[i] = 1 if _prices[i] > _prices[i - _mp] else -1
384
+ # Simulate portfolio
385
+ _cap = float(_initial_capital)
386
+ _position = 0.0 # shares
387
+ _cash = _cap
388
+ _trades = 0
389
+ _portfolio = []
390
+ for i in range(1, n):
391
+ _p = _prices[i]
392
+ _sig = _signals[i - 1]
393
+ if _sig == 1 and _position == 0 and _cash > 0:
394
+ _shares = _cash / _p
395
+ _position = _shares
396
+ _cash = 0
397
+ _trades += 1
398
+ elif _sig == -1 and _position > 0:
399
+ _cash = _position * _p
400
+ _position = 0
401
+ _trades += 1
402
+ _portfolio.append(_cash + _position * _p)
403
+ if not _portfolio:
404
+ return None
405
+ _final = _portfolio[-1]
406
+ _total_return = (_final - _cap) / _cap
407
+ _bh_return = (_prices[-1] - _prices[0]) / _prices[0]
408
+ # Daily returns for Sharpe
409
+ _rets = [(_portfolio[i] - _portfolio[i-1]) / _portfolio[i-1] for i in range(1, len(_portfolio)) if _portfolio[i-1] > 0]
410
+ _ann_return = sum(_rets) / len(_rets) * 252 if _rets else 0
411
+ _ann_vol = _stats.stdev(_rets) * (252 ** 0.5) if len(_rets) > 1 else 0
412
+ _sharpe = _ann_return / _ann_vol if _ann_vol > 0 else 0
413
+ # Max drawdown
414
+ _peak = _portfolio[0]
415
+ _max_dd = 0.0
416
+ for v in _portfolio:
417
+ if v > _peak:
418
+ _peak = v
419
+ _dd = (_peak - v) / _peak if _peak > 0 else 0
420
+ if _dd > _max_dd:
421
+ _max_dd = _dd
422
+ # Equity curve (sampled monthly)
423
+ _step = max(1, n // 24)
424
+ _equity_curve = [
425
+ {"date": str(_close.index[min(i + 1, n - 1)].date()), "strategy": round(_portfolio[min(i, len(_portfolio)-1)], 2)}
426
+ for i in range(0, len(_portfolio), _step)
427
+ ]
428
+ _win_trades = sum(1 for i in range(1, len(_portfolio)) if _portfolio[i] > _portfolio[i-1])
429
+ _vol = _df["Volume"].dropna() if "Volume" in _df else []
430
+ _vol_count = len(_vol) if hasattr(_vol, "__len__") else 0
431
+ return {
432
+ "success": True,
433
+ "symbol": symbol,
434
+ "strategy": strategy,
435
+ "total_return": round(_total_return, 4),
436
+ "buy_hold_return": round(_bh_return, 4),
437
+ "annualized_return": round(_ann_return, 4),
438
+ "sharpe_ratio": round(_sharpe, 3),
439
+ "max_drawdown": round(-_max_dd, 4),
440
+ "win_rate": round(_win_trades / max(len(_portfolio) - 1, 1), 3),
441
+ "num_trades": _trades,
442
+ "equity_curve": _equity_curve,
443
+ "data_provider": "yfinance",
444
+ "provider_chain": ["yfinance"],
445
+ "start_date": start_date,
446
+ "end_date": end_date,
447
+ "initial_capital": float(_initial_capital),
448
+ "bars": n,
449
+ "volume_summary": {
450
+ "last": round(float(_vol.iloc[-1]), 2) if _vol_count else None,
451
+ "average": round(float(_vol.mean()), 2) if _vol_count else None,
452
+ "min": round(float(_vol.min()), 2) if _vol_count else None,
453
+ "max": round(float(_vol.max()), 2) if _vol_count else None,
454
+ "coverage": round(_vol_count / max(len(_df), 1), 4),
455
+ },
456
+ }
457
+
458
+ yf_result = await asyncio.get_event_loop().run_in_executor(None, _run_yf_backtest)
459
+ if yf_result and yf_result.get("success"):
460
+ return {"success": True, "data": yf_result, "_source": "yfinance-local"}
461
+ if yf_result and not yf_result.get("success"):
462
+ return yf_result
463
+ except Exception as _e:
464
+ logger.debug("yfinance direct backtest failed: %s", _e)
465
+
466
+ import aiohttp
467
+ payload = {
468
+ "symbols": [symbol],
469
+ "strategy_type": strategy,
470
+ "start_date": start_date,
471
+ "end_date": end_date,
472
+ "initial_capital": float(_initial_capital),
473
+ "commission_rate": 0.0003,
474
+ "include_monte_carlo": False,
475
+ }
476
+ try:
477
+ async with aiohttp.ClientSession() as sess:
478
+ async with sess.post(f"{api_url}/api/v1/backtest", json=payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
479
+ if resp.status == 200:
480
+ body = await resp.json()
481
+ _rest_data = body.get("data", body)
482
+ if _rest_data and isinstance(_rest_data, dict):
483
+ return {"success": True, "data": _rest_data, "_source": "rest"}
484
+ except Exception as _e:
485
+ logger.debug("backtest REST call failed: %s", _e)
486
+ # Honest, actionable error: the dominant cause is too little history
487
+ # (new IPO / halted / wrong ticker), not "all data sources down".
488
+ if 0 < _yf_bars[0] < 5:
489
+ return {"success": False, "error": format_backtest_data_error(
490
+ symbol,
491
+ start_date=start_date,
492
+ end_date=end_date,
493
+ bars=_yf_bars[0],
494
+ )}
495
+ return {"success": False, "error": format_backtest_data_error(
496
+ symbol,
497
+ start_date=start_date,
498
+ end_date=end_date,
499
+ local_error=local_error,
500
+ bars=_yf_bars[0],
501
+ )}
502
+
503
+ if self.context.has_rich:
504
+ with self.context.console.status(f"[dim]{label}...[/dim]", spinner="dots"):
505
+ result = await _do_backtest()
506
+ else:
507
+ print(label)
508
+ result = await _do_backtest()
509
+
510
+ # Guard: execute_aria_tool / REST fallback can return None
511
+ if not result:
512
+ print_error(self.context, "回测服务不可用 (API未运行) — 先启动后端: cd apps/api && python -m uvicorn src.main:app", "tool")
513
+ return
514
+
515
+ if result.get("success"):
516
+ d = result.get("data", result)
517
+ if not isinstance(d, dict):
518
+ print_error(self.context, f"回测结果格式异常: {type(d)}", "tool")
519
+ return
520
+ src = result.get("_source", "aria")
521
+ if self.context.has_rich:
522
+ from rich.table import Table
523
+ tbl = Table(title=f"[bold]{symbol} · {strategy.upper()}[/bold]", show_header=True, header_style="bold")
524
+ tbl.add_column("Metric", style="#57606a")
525
+ tbl.add_column("Value", justify="right")
526
+ tbl.add_column("vs B&H", justify="right", style="#57606a")
527
+ bh = self._bt_num(self._bt_value(d, "buy_hold_return", "benchmark_return", default=0))
528
+ trades = self._bt_trade_count(d)
529
+ rows = [
530
+ ("Total Return", self._bt_pct(d.get("total_return")), self._bt_pct(bh)),
531
+ ("Ann. Return", self._bt_pct(self._bt_value(d, "annualized_return", "annual_return", default=0)), ""),
532
+ ("Sharpe Ratio", f"{self._bt_num(d.get('sharpe_ratio')):.2f}", ""),
533
+ ("Max Drawdown", self._bt_pct(d.get("max_drawdown")), ""),
534
+ ("Win Rate", self._bt_pct(d.get("win_rate")), ""),
535
+ ("# Trades", str(trades), ""),
536
+ ]
537
+ if d.get("calmar_ratio"):
538
+ rows.append(("Calmar Ratio", f"{d['calmar_ratio']:.2f}", ""))
539
+ if d.get("sortino_ratio"):
540
+ rows.append(("Sortino Ratio", f"{d['sortino_ratio']:.2f}", ""))
541
+ for r in rows:
542
+ tbl.add_row(*r)
543
+ self.context.console.print(tbl)
544
+ self.context.console.print(f" [bold]{self._bt_result_summary(d)}[/bold]")
545
+
546
+ actual_start = self._bt_value(d, "start", "start_date", default=start_date)
547
+ actual_end = self._bt_value(d, "end", "end_date", default=end_date)
548
+ bars = self._bt_int(d.get("bars"))
549
+ initial = self._bt_money(d.get("initial_capital", _initial_capital))
550
+ self.context.console.print(
551
+ f" [#57606a]source:[/#57606a] {src}"
552
+ f" [#57606a]period:[/#57606a] {actual_start} → {actual_end}"
553
+ f" [#57606a]bars:[/#57606a] {bars}"
554
+ f" [#57606a]capital:[/#57606a] {initial}"
555
+ )
556
+ self.context.console.print(
557
+ f" [#57606a]params:[/#57606a] "
558
+ f"momentum={_momentum_period} fast={_fast_period} slow={_slow_period}"
559
+ )
560
+ if d.get("provider_chain"):
561
+ chain = " → ".join(str(x) for x in d.get("provider_chain") or [])
562
+ status = d.get("data_status") or "complete"
563
+ missing = ", ".join(str(x) for x in (d.get("missing_fields") or [])) or "none"
564
+ self.context.console.print(
565
+ f" [#57606a]data:[/#57606a] {chain}"
566
+ f" [#57606a]status:[/#57606a] {status}"
567
+ f" [#57606a]missing:[/#57606a] {missing}"
568
+ )
569
+ vol = self._bt_volume_summary(d)
570
+ if vol:
571
+ avg = vol.get("average")
572
+ last = vol.get("last")
573
+ coverage = self._bt_num(vol.get("coverage"))
574
+ self.context.console.print(
575
+ f" [#57606a]volume:[/#57606a] "
576
+ f"avg {avg:,.0f} · last {last:,.0f} · coverage {coverage:.0%}"
577
+ if avg is not None and last is not None
578
+ else " [#57606a]volume:[/#57606a] unavailable"
579
+ )
580
+ if d.get("report_path"):
581
+ self.context.console.print(f" [#57606a]report:[/#57606a] {d['report_path']}")
582
+ if trades == 0:
583
+ self.context.console.print(
584
+ " [yellow]注意:[/yellow] # Trades 为 0,表示本次规则没有触发入场;"
585
+ "收益可能来自全程空仓/持仓逻辑或上游交易统计口径。"
586
+ )
587
+ else:
588
+ print(f"Total Return: {d.get('total_return',0)*100:.1f}% Sharpe: {d.get('sharpe_ratio',0):.2f} MaxDD: {d.get('max_drawdown',0)*100:.1f}%")
589
+ if d.get("report_path"):
590
+ print(f"HTML Report: {d['report_path']}")
591
+
592
+ eq = d.get("equity_curve", [])
593
+ if eq:
594
+ strat_vals = [p.get("strategy", p.get("portfolio_value", 0)) for p in eq if isinstance(p, dict)]
595
+ if strat_vals:
596
+ spark = format_sparkline(strat_vals)
597
+ if spark:
598
+ self.context.console.print(f" [#57606a]Equity:[/#57606a] [green]{spark}[/green]" if self.context.has_rich else f" Equity: {spark}")
599
+ await self._print_backtest_broker_plan(d)
600
+ else:
601
+ print_error(self.context, f"Backtest failed: {result.get('error', 'Unknown')}", "tool")
602
+
603
+ async def _print_backtest_broker_plan(self, backtest_result: dict):
604
+ """Print an account-aware order plan for a successful backtest, if a broker is connected."""
605
+
606
+ if not _get__HAS_BROKERS() or not isinstance(backtest_result, dict):
607
+ return
608
+ try:
609
+ reg = _get_broker_registry()
610
+ broker = reg.active() if reg else None
611
+ if not broker:
612
+ return
613
+ from brokers import plans_from_strategy_results, snapshot_from_broker
614
+ import asyncio as _aio
615
+
616
+ def _build_plan():
617
+ snapshot = snapshot_from_broker(broker)
618
+ plans = plans_from_strategy_results(snapshot, [backtest_result])
619
+ return snapshot, plans[0] if plans else None
620
+
621
+ snapshot, plan = await _aio.get_event_loop().run_in_executor(None, _build_plan)
622
+ if not plan:
623
+ return
624
+ data = plan.to_dict()
625
+ order = data.get("estimated_order") or {}
626
+ risk = data.get("risk") or {}
627
+ if self.context.has_rich:
628
+ from rich.table import Table
629
+ t = Table(title=f"Broker Plan — {snapshot.broker_label}", show_header=False, box=None)
630
+ t.add_column("Field", style="dim")
631
+ t.add_column("Value")
632
+ t.add_row("Current Weight", f"{data.get('current_weight', 0) * 100:.2f}%")
633
+ t.add_row("Target Weight", f"{data.get('target_weight', 0) * 100:.2f}%")
634
+ if order:
635
+ side = "买入" if order.get("side") == "buy" else "卖出"
636
+ t.add_row("Suggested Order", f"{side} {order.get('quantity', 0):,.0f} {data.get('symbol')} @ {order.get('price', 0):,.2f}")
637
+ t.add_row("Estimated Value", f"{snapshot.currency} {order.get('estimated_value', 0):,.2f}")
638
+ t.add_row("Cash After", f"{snapshot.currency} {data.get('cash_after', 0):,.2f}")
639
+ else:
640
+ t.add_row("Suggested Order", "No trade")
641
+ status = "passed" if risk.get("passed") else "blocked"
642
+ t.add_row("Risk Gate", status)
643
+ self.context.console.print(t)
644
+ for msg in risk.get("violations", []):
645
+ self.context.console.print(f" [red]- {msg}[/red]")
646
+ for msg in risk.get("warnings", []):
647
+ self.context.console.print(f" [yellow]- {msg}[/yellow]")
648
+ if order and risk.get("passed"):
649
+ self.context.console.print(" [dim]这是订单计划,不会自动下单。执行前仍需用户明确确认。[/dim]")
650
+ else:
651
+ print(f"Broker Plan: {snapshot.broker_label}")
652
+ if order:
653
+ print(f" {order.get('side')} {order.get('quantity')} {data.get('symbol')} @ {order.get('price')}")
654
+ print(f" Risk: {'passed' if risk.get('passed') else 'blocked'}")
655
+ except Exception as exc:
656
+ logger.debug("backtest broker plan skipped: %s", exc)
657
+
658
+ async def cmd_walk_forward(self, args: str):
659
+ """Walk-Forward 滚动回测 → /api/v1/backtest/walk-forward"""
660
+
661
+ parts = args.split() if args else ["SPY"]
662
+ symbol = parts[0].upper() if parts else "SPY"
663
+ strategy = parts[1] if len(parts) > 1 else "momentum"
664
+ method = parts[2] if len(parts) > 2 else "rolling"
665
+ api_url = self.terminal.config.get("api_url", "http://localhost:8000")
666
+
667
+ label = f"Walk-Forward ({method}) · {strategy} · {symbol}"
668
+ import aiohttp
669
+
670
+ async def _do_wf():
671
+ payload = {
672
+ "symbol": symbol, "strategy_type": strategy, "method": method,
673
+ "start_date": "2020-01-01",
674
+ "end_date": __import__("datetime").date.today().isoformat(),
675
+ "train_period_days": 252, "test_period_days": 63, "step_days": 21,
676
+ }
677
+ async with aiohttp.ClientSession() as sess:
678
+ async with sess.post(f"{api_url}/api/v1/backtest/walk-forward", json=payload, timeout=aiohttp.ClientTimeout(total=90)) as resp:
679
+ if resp.status != 200:
680
+ raise RuntimeError(f"HTTP {resp.status}")
681
+ body = await resp.json()
682
+ return body.get("data", body)
683
+
684
+ if self.context.has_rich:
685
+ with self.context.console.status(f"[dim]{label}...[/dim]", spinner="dots"):
686
+ try:
687
+ data = await _do_wf()
688
+ except Exception as e:
689
+ print_error(self.context, str(e), "tool"); return
690
+ else:
691
+ print(label)
692
+ try:
693
+ data = await _do_wf()
694
+ except Exception as e:
695
+ print_error(self.context, str(e), "tool"); return
696
+
697
+ summary = data.get("summary", data)
698
+ folds = data.get("folds", [])
699
+ verdict = summary.get("verdict", "?")
700
+ verdict_color = "green" if verdict == "PASS" else "red"
701
+
702
+ if self.context.has_rich:
703
+ from rich.table import Table
704
+ # Summary
705
+ self.context.console.print(f"\n[bold]{symbol} · {strategy} · {method}[/bold] Verdict: [bold {verdict_color}]{verdict}[/bold {verdict_color}]")
706
+ self.context.console.print(f" Folds: {summary.get('n_folds')} "
707
+ f"Avg OOS Sharpe: [bold]{summary.get('avg_oos_sharpe', 0):.3f}[/bold] "
708
+ f"Consistency: {summary.get('consistency_ratio_pct', 0):.0f}% "
709
+ f"Robustness: {summary.get('robustness_score', 0):.3f} "
710
+ f"p-value: {summary.get('p_value', 1):.4f}")
711
+ # Fold table
712
+ if folds:
713
+ tbl = Table(title="Fold Results", show_header=True, header_style="bold dim")
714
+ for col in ["Fold", "Test Period", "OOS Return", "OOS Sharpe", "OOS MaxDD", "Win%"]:
715
+ tbl.add_column(col, justify="right")
716
+ for f in folds[:12]:
717
+ ret = f.get("test_return_pct", 0)
718
+ tbl.add_row(
719
+ str(f.get("fold_id", "")),
720
+ f.get("test_period", ""),
721
+ f"{'+'if ret>=0 else ''}{ret:.1f}%",
722
+ f"{f.get('test_sharpe', 0):.3f}",
723
+ f"{f.get('test_max_drawdown_pct', 0):.1f}%",
724
+ f"{f.get('test_win_rate_pct', 0):.0f}%",
725
+ )
726
+ self.context.console.print(tbl)
727
+ else:
728
+ print(f"Verdict: {verdict} Folds: {summary.get('n_folds')} Avg OOS Sharpe: {summary.get('avg_oos_sharpe',0):.3f}")
729
+
730
+ async def cmd_auto_strategy(self, args: str):
731
+ """AI strategy auto-optimization loop (unique to Aria).
732
+
733
+ Generates a strategy, runs backtest, reads results, iterates until
734
+ the target metric is reached or max rounds exhausted.
735
+
736
+ Usage:
737
+ /auto-strategy momentum SPY
738
+ /auto-strategy momentum SPY --target sharpe=1.5
739
+ /auto-strategy meanrev AAPL --target sharpe=1.2 --rounds 3
740
+ """
741
+
742
+ import re as _re
743
+ import time as _time
744
+
745
+ parts = args.split()
746
+ strategy_type = parts[0].lower() if parts else "momentum"
747
+ symbol = parts[1].upper() if len(parts) > 1 else "SPY"
748
+ target_sharpe = 1.0
749
+ max_rounds = 3
750
+ for p in parts[2:]:
751
+ m = _re.match(r"--target\s*sharpe=([0-9.]+)", p)
752
+ if m:
753
+ target_sharpe = float(m.group(1))
754
+ m = _re.match(r"--rounds=?([0-9]+)", p)
755
+ if m:
756
+ max_rounds = int(m.group(1))
757
+
758
+ if self.context.has_rich:
759
+ self.context.console.print()
760
+ self.context.console.print(f" [bold cyan]🔄 策略自动优化[/bold cyan] [dim]{strategy_type} / {symbol} 目标 Sharpe≥{target_sharpe} 最多{max_rounds}轮[/dim]")
761
+ self.context.console.print()
762
+
763
+ best_sharpe = 0.0
764
+ best_version = None
765
+
766
+ for round_num in range(1, max_rounds + 1):
767
+ self.context.console.print(f" [bold]第 {round_num}/{max_rounds} 轮[/bold]") if self.context.has_rich else print(f" Round {round_num}/{max_rounds}")
768
+
769
+ # ── Step 1: Generate strategy code ──────────────────────────────
770
+ feedback_ctx = ""
771
+ if round_num > 1 and best_version:
772
+ feedback_ctx = (
773
+ f"\n\nPrevious backtest Sharpe={best_sharpe:.2f} (target={target_sharpe})."
774
+ " Modify the strategy to improve Sharpe: adjust lookback period, "
775
+ "add momentum filter, tighten stop-loss, or change position sizing."
776
+ )
777
+
778
+ gen_prompt = (
779
+ f"Generate a complete, self-contained Python backtest strategy script.\n"
780
+ f"Strategy type: {strategy_type}\n"
781
+ f"Symbol: {symbol}\n"
782
+ f"Requirements:\n"
783
+ f"1. Use yfinance to download 2 years of daily OHLCV data\n"
784
+ f"2. Implement the {strategy_type} strategy with clear entry/exit signals\n"
785
+ f"3. Simulate trades: track portfolio value, returns, Sharpe ratio\n"
786
+ f"4. Print EXACTLY this at the end (machine-parseable):\n"
787
+ f" BACKTEST_RESULT: sharpe=X.XX annual_return=X.XX% max_drawdown=X.XX% trades=N\n"
788
+ f"5. All code in one file, no external dependencies except yfinance/pandas/numpy\n"
789
+ f"{feedback_ctx}\n"
790
+ f"Output ONLY the Python code in ```python``` fences."
791
+ )
792
+
793
+ _fname = f"auto_strat_{strategy_type}_{symbol}_r{round_num}_{int(_time.time())}.py"
794
+ from artifacts import user_generated_dir as _user_generated_dir
795
+ _fpath = _user_generated_dir() / _fname
796
+
797
+ self.context.console.print(" [dim]生成策略代码...[/dim]") if self.context.has_rich else print(" Generating strategy...")
798
+ await self.terminal.send_message(gen_prompt)
799
+
800
+ # Extract code from last response
801
+ last_ai = next(
802
+ (m["content"] for m in reversed(self.terminal.conversation)
803
+ if m.get("role") == "assistant"), ""
804
+ )
805
+ import re as _re2
806
+ py_blocks = _re2.findall(r"```python\n(.*?)```", last_ai, _re2.DOTALL)
807
+ if not py_blocks:
808
+ # fallback: grab after fence
809
+ m = _re2.search(r"```python\n(.*)", last_ai, _re2.DOTALL)
810
+ if m:
811
+ py_blocks = [m.group(1)]
812
+
813
+ if not py_blocks:
814
+ self.context.console.print(" [yellow]⚠ 未生成代码,跳过本轮[/yellow]") if self.context.has_rich else print(" No code generated, skipping")
815
+ continue
816
+
817
+ code = py_blocks[-1].strip()
818
+ _tool_write_file({"path": str(_fpath), "content": code, "_skip_confirm": True})
819
+ self.context.console.print(f" [dim]策略已保存: {_fpath.name}[/dim]") if self.context.has_rich else print(f" Saved: {_fpath.name}")
820
+
821
+ # ── Step 2: Run backtest ─────────────────────────────────────────
822
+ self.context.console.print(" [dim]运行回测...[/dim]") if self.context.has_rich else print(" Running backtest...")
823
+ bt_result = _tool_run_command({
824
+ "command": f"python3 {_fpath}",
825
+ "timeout": 120,
826
+ })
827
+ stdout = bt_result.get("data", {}).get("stdout", "") or ""
828
+ stderr = bt_result.get("data", {}).get("stderr", "") or ""
829
+
830
+ # ── Step 3: Parse backtest metrics ──────────────────────────────
831
+ sharpe = 0.0
832
+ ann_return = 0.0
833
+ max_dd = 0.0
834
+ n_trades = 0
835
+ m = _re2.search(r"BACKTEST_RESULT:.*?sharpe=([0-9.-]+)", stdout)
836
+ if m:
837
+ sharpe = float(m.group(1))
838
+ m = _re2.search(r"annual_return=([0-9.-]+)%", stdout)
839
+ if m:
840
+ ann_return = float(m.group(1))
841
+ m = _re2.search(r"max_drawdown=([0-9.-]+)%", stdout)
842
+ if m:
843
+ max_dd = float(m.group(1))
844
+ m = _re2.search(r"trades=([0-9]+)", stdout)
845
+ if m:
846
+ n_trades = int(m.group(1))
847
+
848
+ # Update best
849
+ if sharpe > best_sharpe:
850
+ best_sharpe = sharpe
851
+ best_version = _fpath
852
+
853
+ # Display round result
854
+ sharpe_color = "green" if sharpe >= target_sharpe else ("yellow" if sharpe > 0 else "red")
855
+ if self.context.has_rich:
856
+ self.context.console.print(
857
+ f" [dim]回测结果:[/dim] "
858
+ f"Sharpe=[{sharpe_color}]{sharpe:.2f}[/{sharpe_color}] "
859
+ f"年化={ann_return:.1f}% "
860
+ f"最大回撤={max_dd:.1f}% "
861
+ f"交易次数={n_trades}"
862
+ )
863
+ else:
864
+ print(f" Backtest: Sharpe={sharpe:.2f} Return={ann_return:.1f}% MaxDD={max_dd:.1f}% Trades={n_trades}")
865
+
866
+ if stderr and "Error" in stderr:
867
+ self.context.console.print(f" [red]执行错误: {stderr[:200]}[/red]") if self.context.has_rich else print(f" Error: {stderr[:200]}")
868
+
869
+ # ── Step 4: Check convergence ────────────────────────────────────
870
+ if sharpe >= target_sharpe:
871
+ self.context.console.print(f"\n [green]✅ 目标达成!Sharpe={sharpe:.2f} ≥ {target_sharpe}[/green]") if self.context.has_rich else print(f"\n ✓ Target reached: Sharpe={sharpe:.2f}")
872
+ break
873
+ elif round_num < max_rounds:
874
+ self.context.console.print(f" [dim]Sharpe={sharpe:.2f} < 目标{target_sharpe},继续优化...[/dim]\n") if self.context.has_rich else print(f" Sharpe={sharpe:.2f} < {target_sharpe}, optimizing...\n")
875
+
876
+ # ── Summary ──────────────────────────────────────────────────────────
877
+ if self.context.has_rich:
878
+ self.context.console.print()
879
+ self.context.console.print(f" [bold]优化完成[/bold] 最佳 Sharpe=[{'green' if best_sharpe >= target_sharpe else 'yellow'}]{best_sharpe:.2f}[/{'green' if best_sharpe >= target_sharpe else 'yellow'}]")
880
+ if best_version:
881
+ self.context.console.print(f" 最优策略文件: [dim]{best_version}[/dim]")
882
+ self.context.console.print(f" [dim]运行: python3 {best_version}[/dim]")
883
+ self.context.console.print()
884
+ else:
885
+ print(f"\n Best Sharpe={best_sharpe:.2f} File: {best_version}")
886
+
887
+ async def cmd_factor_lab(self, args: str):
888
+ """Factor analysis workstation — compute IC, ICIR, factor returns (Aria exclusive).
889
+
890
+ Usage:
891
+ /factor-lab AAPL
892
+ /factor-lab QQQ --days 252
893
+ /factor-lab SPY --factors momentum,value,quality
894
+ """
895
+
896
+ import re as _re
897
+
898
+ parts = args.split()
899
+ symbol = parts[0].upper() if parts else "SPY"
900
+ days = 252
901
+ for p in parts[1:]:
902
+ m = _re.match(r"--days=?(\d+)", p)
903
+ if m:
904
+ days = int(m.group(1))
905
+
906
+ if self.context.has_rich:
907
+ self.context.console.print()
908
+ self.context.console.print(f" [bold cyan]🔬 因子分析工作台[/bold cyan] [dim]{symbol} {days}天数据[/dim]")
909
+ self.context.console.print()
910
+
911
+ if not _get__HAS_MDC():
912
+ self.context.console.print("[red]需要 market_data_client 模块[/red]") if self.context.has_rich else print("market_data_client not available")
913
+ return
914
+
915
+ try:
916
+ import numpy as np
917
+ import pandas as pd
918
+
919
+ mdc = _get_mdc()
920
+
921
+ # ── Fetch data ────────────────────────────────────────────────────
922
+ self.context.console.print(" [dim]拉取行情数据...[/dim]") if self.context.has_rich else print(" Fetching data...")
923
+ hist = mdc.history(symbol, days=days)
924
+ if not hist.get("success") or not hist.get("data"):
925
+ self.context.console.print(f"[red]无法获取 {symbol} 历史数据[/red]") if self.context.has_rich else print(f"No data for {symbol}")
926
+ return
927
+
928
+ df = pd.DataFrame(hist["data"])
929
+ df["close"] = pd.to_numeric(df["close"], errors="coerce")
930
+ df["volume"] = pd.to_numeric(df.get("volume", pd.Series()), errors="coerce")
931
+ df = df.dropna(subset=["close"])
932
+ close = df["close"]
933
+ returns = close.pct_change().dropna()
934
+
935
+ # ── Compute factors ───────────────────────────────────────────────
936
+ factors: dict = {}
937
+
938
+ # 1. Momentum (1M, 3M, 6M, 12M)
939
+ for months, label in [(21, "Mom1M"), (63, "Mom3M"), (126, "Mom6M"), (252, "Mom12M")]:
940
+ if len(close) > months:
941
+ factors[label] = close.pct_change(months)
942
+
943
+ # 2. Mean Reversion (short-term)
944
+ if len(close) > 5:
945
+ factors["MeanRev5D"] = -close.pct_change(5)
946
+
947
+ # 3. Volatility (annualized)
948
+ if len(returns) > 20:
949
+ factors["Vol20D"] = returns.rolling(20).std() * np.sqrt(252)
950
+
951
+ # 4. Volume trend
952
+ if "volume" in df.columns and df["volume"].notna().sum() > 20:
953
+ vol_series = df["volume"].astype(float)
954
+ factors["VolTrend"] = vol_series.pct_change(20)
955
+
956
+ # 5. RSI factor
957
+ delta = close.diff()
958
+ gain = delta.clip(lower=0).rolling(14).mean()
959
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
960
+ rs = gain / loss.replace(0, np.nan)
961
+ factors["RSI14"] = 100 - 100 / (1 + rs)
962
+
963
+ # ── Compute IC (Information Coefficient) for each factor ──────────
964
+ # IC = correlation between factor value at t and next-period return
965
+ fwd_returns = returns.shift(-1) # 1-day forward return
966
+
967
+ ic_results = {}
968
+ for fname, fseries in factors.items():
969
+ try:
970
+ aligned = pd.concat([fseries, fwd_returns], axis=1).dropna()
971
+ aligned.columns = ["factor", "fwd"]
972
+ if len(aligned) < 20:
973
+ continue
974
+ ic = aligned["factor"].corr(aligned["fwd"])
975
+ if np.isnan(ic):
976
+ continue
977
+ # Rolling IC (window=20) — compute manually to avoid rolling.apply issues
978
+ roll_ics = []
979
+ for start in range(0, len(aligned) - 20, 5):
980
+ chunk = aligned.iloc[start:start + 20]
981
+ chunk_ic = chunk["factor"].corr(chunk["fwd"])
982
+ if not np.isnan(chunk_ic):
983
+ roll_ics.append(chunk_ic)
984
+ icir = ic / (np.std(roll_ics) + 1e-9) if len(roll_ics) >= 3 else 0.0
985
+ ic_results[fname] = {"ic": ic, "icir": float(icir), "abs_ic": abs(ic)}
986
+ except Exception:
987
+ continue
988
+
989
+ # ── Current factor values (latest bar) ───────────────────────────
990
+ latest = {fname: float(fseries.dropna().iloc[-1]) if not fseries.dropna().empty else None
991
+ for fname, fseries in factors.items()}
992
+
993
+ # ── Display results ───────────────────────────────────────────────
994
+ if self.context.has_rich:
995
+ self.context.console.print(f" [bold]{symbol}[/bold] [dim]当前价: {close.iloc[-1]:.2f} 数据: {len(df)}天[/dim]")
996
+ self.context.console.print()
997
+ self.context.console.print(" [bold]因子分析[/bold]")
998
+ self.context.console.print()
999
+ self.context.console.print(f" [dim]{'因子':<14s}{'IC':>8s}{'|IC|':>8s}{'ICIR':>8s}{'当前值':>12s} 信号[/dim]")
1000
+ self.context.console.print(" " + "─" * 60)
1001
+ for fname, metrics in sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"])):
1002
+ ic = metrics["ic"]
1003
+ icir = metrics["icir"]
1004
+ curr = latest.get(fname)
1005
+ curr_str = f"{curr:.3f}" if curr is not None else "N/A"
1006
+ signal = ""
1007
+ if abs(ic) > 0.03:
1008
+ signal = "↑ 看多" if ic > 0 else "↓ 看空"
1009
+ ic_color = "green" if ic > 0.03 else ("red" if ic < -0.03 else "dim")
1010
+ self.context.console.print(
1011
+ f" [{ic_color}]{fname:<14s}[/{ic_color}]"
1012
+ f"[{ic_color}]{ic:>8.3f}[/{ic_color}]"
1013
+ f"{abs(ic):>8.3f}"
1014
+ f"{icir:>8.2f}"
1015
+ f"{curr_str:>12s}"
1016
+ f" [dim]{signal}[/dim]"
1017
+ )
1018
+ self.context.console.print()
1019
+ # AI interpretation
1020
+ top_factors = sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"]))[:3]
1021
+ if top_factors:
1022
+ self.context.console.print(" [bold]AI 解读[/bold]")
1023
+ fac_summary = ", ".join(f"{f}(IC={m['ic']:.3f})" for f, m in top_factors)
1024
+ self.context.console.print(f" [dim]最有效因子: {fac_summary}[/dim]")
1025
+ self.context.console.print(f" [dim]使用 /deep-analysis {symbol} 获取完整 AI 投研分析[/dim]")
1026
+ self.context.console.print()
1027
+ else:
1028
+ print(f" {symbol} Factor Analysis ({len(df)} days)")
1029
+ print(f" {'Factor':<14} {'IC':>8} {'|IC|':>8} {'ICIR':>8} {'Current':>12}")
1030
+ for fname, metrics in sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"])):
1031
+ curr = latest.get(fname)
1032
+ curr_str = f"{curr:.3f}" if curr is not None else "N/A"
1033
+ print(f" {fname:<14} {metrics['ic']:>8.3f} {abs(metrics['ic']):>8.3f} {metrics['icir']:>8.2f} {curr_str:>12}")
1034
+
1035
+ except ImportError as e:
1036
+ self.context.console.print(f"[red]需要 numpy/pandas: {e}[/red]") if self.context.has_rich else print(f"Missing: {e}")
1037
+ except Exception as e:
1038
+ self.context.console.print(f"[red]因子分析失败: {e}[/red]") if self.context.has_rich else print(f"Error: {e}")
1039
+
1040
+ def _scaffold_with_llm(self, project_name: str, description: str, base_dir) -> None:
1041
+ """Call the configured LLM to generate a custom project structure and write files."""
1042
+
1043
+ import json
1044
+ import urllib.request
1045
+ import textwrap
1046
+ import pathlib
1047
+
1048
+ ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
1049
+ model = self.terminal.config.get("model", "qwen2.5:7b")
1050
+
1051
+ _SCAFFOLD_SYS = (
1052
+ "You are a project scaffolding assistant. Output ONLY valid JSON — no markdown, no explanation.\n"
1053
+ "Schema:\n"
1054
+ '{"description": "one-line summary", "entry": "main.py", '
1055
+ '"files": {"relative/path.py": "file content", ...}}\n'
1056
+ "CRITICAL JSON rules:\n"
1057
+ r'- Inside string values use \n for newlines (backslash-n), NEVER literal newlines.'
1058
+ "\n"
1059
+ r'- Inside string values use \" for double quotes, \\ for backslashes.'
1060
+ "\n"
1061
+ "- 3–8 files total. Content must be complete and runnable.\n"
1062
+ "- Always include: main entry point, requirements.txt, README.md\n"
1063
+ "- requirements.txt: one package per line. README.md: install + usage.\n"
1064
+ "- No markdown code fences. Raw JSON only."
1065
+ )
1066
+ _SCAFFOLD_USER = (
1067
+ f"Project name: {project_name}\n"
1068
+ f"Description: {description}\n"
1069
+ "Generate the complete file structure."
1070
+ )
1071
+
1072
+ if self.context.has_rich:
1073
+ self.context.console.print(f"\n [#C08050]⏺[/#C08050] [bold]LLM 生成项目结构[/bold] [dim]{description}[/dim]")
1074
+ else:
1075
+ print(f"\n⏺ 生成项目结构: {description}")
1076
+
1077
+ payload = {
1078
+ "model": model,
1079
+ "messages": [
1080
+ {"role": "system", "content": _SCAFFOLD_SYS},
1081
+ {"role": "user", "content": _SCAFFOLD_USER},
1082
+ ],
1083
+ "stream": False,
1084
+ "options": {"temperature": 0.2, "num_predict": 4096},
1085
+ }
1086
+ try:
1087
+ req = urllib.request.Request(
1088
+ ollama_url.rstrip("/") + "/api/chat",
1089
+ data=json.dumps(payload).encode(),
1090
+ headers={"Content-Type": "application/json"},
1091
+ )
1092
+ with urllib.request.urlopen(req, timeout=60) as r:
1093
+ data = json.loads(r.read())
1094
+ raw = data.get("message", {}).get("content", "").strip()
1095
+ except Exception as e:
1096
+ msg = f"LLM 调用失败: {e}"
1097
+ self.context.console.print(f" [red]{msg}[/red]") if self.context.has_rich else print(f" {msg}")
1098
+ return
1099
+
1100
+ # Strip accidental markdown fences
1101
+ import re as _re
1102
+ raw = _re.sub(r'^```[a-z]*\n?', '', raw).rstrip('`').strip()
1103
+
1104
+ def _parse_scaffold_json(text: str):
1105
+ """Try several strategies to extract valid JSON from LLM output."""
1106
+ # Strategy 1: strict parse
1107
+ try:
1108
+ return json.loads(text)
1109
+ except json.JSONDecodeError:
1110
+ pass
1111
+ # Strategy 2: replace literal newlines inside string values
1112
+ try:
1113
+ # Escape literal newlines that appear inside JSON string values
1114
+ fixed = _re.sub(
1115
+ r'("(?:[^"\\]|\\.)*")',
1116
+ lambda m: m.group().replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t'),
1117
+ text,
1118
+ )
1119
+ return json.loads(fixed)
1120
+ except Exception:
1121
+ pass
1122
+ # Strategy 3: find outermost {...} block
1123
+ m = _re.search(r'\{.*\}', text, _re.DOTALL)
1124
+ if m:
1125
+ try:
1126
+ return json.loads(m.group())
1127
+ except Exception:
1128
+ # Strategy 4: same but with newline escaping
1129
+ try:
1130
+ blob = m.group()
1131
+ fixed = _re.sub(
1132
+ r'("(?:[^"\\]|\\.)*")',
1133
+ lambda mx: mx.group().replace('\n', '\\n').replace('\r', '\\r'),
1134
+ blob,
1135
+ )
1136
+ return json.loads(fixed)
1137
+ except Exception:
1138
+ pass
1139
+ return None
1140
+
1141
+ structure = _parse_scaffold_json(raw)
1142
+ if not structure or "files" not in structure:
1143
+ msg = "LLM 未返回有效 JSON 结构,请重试或使用 --template"
1144
+ self.context.console.print(f" [red]{msg}[/red]") if self.context.has_rich else print(f" {msg}")
1145
+ return
1146
+
1147
+ files: dict = structure["files"]
1148
+ proj_desc = structure.get("description", description)
1149
+ entry = structure.get("entry", "main.py")
1150
+
1151
+ # ── Preview ───────────────────────────────────────────────────────────
1152
+ if self.context.has_rich:
1153
+ self.context.console.print(f" [green]✓[/green] [dim]{proj_desc}[/dim]")
1154
+ self.context.console.print(f"\n [dim]{base_dir.name}/[/dim]")
1155
+ for fname, fcontent in files.items():
1156
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1157
+ self.context.console.print(f" [dim] ├── {fname:<26s}[/dim] {lines} lines")
1158
+ self.context.console.print()
1159
+ choice = self.context.console.input(
1160
+ " [bold]Create these files?[/bold] [dim]\\[y=all / n=cancel / r=review each][/dim] "
1161
+ ).strip().lower()
1162
+ else:
1163
+ print(f"\n {base_dir.name}/")
1164
+ for fname, fcontent in files.items():
1165
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1166
+ print(f" ├── {fname:<26s} {lines} lines")
1167
+ choice = input(" Create these files? [y/all / n=cancel / r=review each] ").strip().lower()
1168
+
1169
+ if choice in ("n", "no"):
1170
+ self.context.console.print("[dim]取消。[/dim]") if self.context.has_rich else print("Cancelled.")
1171
+ return
1172
+
1173
+ approve_each = choice in ("r", "review")
1174
+ created, skipped = [], []
1175
+
1176
+ for fname, fcontent in files.items():
1177
+ target = pathlib.Path(base_dir) / fname
1178
+ target.parent.mkdir(parents=True, exist_ok=True)
1179
+
1180
+ if approve_each:
1181
+ if self.context.has_rich:
1182
+ self.context.console.print(f"\n [dim]{fname}[/dim] ({fcontent.count(chr(10))+1} lines)")
1183
+ sub = self.context.console.input(" [dim]写入? [y/n] [/dim]").strip().lower()
1184
+ else:
1185
+ print(f"\n {fname} ({fcontent.count(chr(10))+1} lines)")
1186
+ sub = input(" 写入? [y/n] ").strip().lower()
1187
+ if sub not in ("y", "yes", ""):
1188
+ skipped.append(fname)
1189
+ continue
1190
+
1191
+ result = _tool_write_file({"path": str(target), "content": fcontent, "_skip_confirm": True})
1192
+ if result["success"]:
1193
+ created.append(fname)
1194
+ else:
1195
+ err = result.get("error", "?")
1196
+ self.context.console.print(f" [red]Failed {fname}: {err}[/red]") if self.context.has_rich else print(f" Failed {fname}: {err}")
1197
+
1198
+ if self.context.has_rich:
1199
+ self.context.console.print()
1200
+ if created:
1201
+ self.context.console.print(f" [green]✓[/green] 创建 {len(created)} 个文件 → [bold]{base_dir}[/bold]")
1202
+ for f in created:
1203
+ self.context.console.print(f" [dim]{f}[/dim]")
1204
+ if skipped:
1205
+ self.context.console.print(f" [dim]跳过: {', '.join(skipped)}[/dim]")
1206
+ self.context.console.print(f"\n [dim]启动: cd \"{base_dir}\" && python3 {entry}[/dim]\n")
1207
+ else:
1208
+ print(f"\n创建 {len(created)} 个文件 → {base_dir}")
1209
+ if skipped:
1210
+ print(f"跳过: {', '.join(skipped)}")
1211
+ print(f"启动: cd \"{base_dir}\" && python3 {entry}")
1212
+
1213
+ def cmd_scaffold(self, args: str):
1214
+ """Generate a project folder structure with files, with user approval.
1215
+
1216
+ Usage:
1217
+ /scaffold <project_name> → blank template
1218
+ /scaffold <project_name> <description...> → LLM generates custom structure
1219
+ /scaffold <project_name> --template analysis → fixed finance template
1220
+ /scaffold <project_name> --template strategy
1221
+ /scaffold <project_name> --template pipeline
1222
+
1223
+ Examples:
1224
+ /scaffold my-api FastAPI REST API with JWT auth and PostgreSQL
1225
+ /scaffold price-alert CLI tool that monitors stock prices and sends alerts
1226
+ /scaffold aapl-analysis --template analysis
1227
+ """
1228
+
1229
+ import textwrap
1230
+
1231
+ parts = args.strip().split()
1232
+ if not parts:
1233
+ if self.context.has_rich:
1234
+ self.context.console.print("[dim]Usage: /scaffold <name> [description] | [--template analysis|strategy|pipeline|blank][/dim]")
1235
+ self.context.console.print("[dim]Examples:[/dim]")
1236
+ self.context.console.print("[dim] /scaffold my-api FastAPI REST API with JWT auth[/dim]")
1237
+ self.context.console.print("[dim] /scaffold price-bot CLI tool that monitors stock prices[/dim]")
1238
+ self.context.console.print("[dim] /scaffold aapl-analysis --template analysis[/dim]")
1239
+ else:
1240
+ print("Usage: /scaffold <name> [description] | [--template analysis|strategy|pipeline|blank]")
1241
+ return
1242
+
1243
+ # Parse project name, template flag, and optional description
1244
+ project_name = parts[0]
1245
+ template = None
1246
+ description = ""
1247
+ if "--template" in parts:
1248
+ idx = parts.index("--template")
1249
+ if idx + 1 < len(parts):
1250
+ template = parts[idx + 1]
1251
+ # remaining words before --template are ignored
1252
+ elif len(parts) > 1:
1253
+ description = " ".join(parts[1:]) # everything after name = LLM description
1254
+
1255
+ # Resolve base directory under the user's local Aria Code workspace.
1256
+ # Generated strategy/code projects must not silently land in the source repo.
1257
+ from artifacts import user_projects_dir as _user_projects_dir
1258
+ base_dir = _user_projects_dir() / project_name
1259
+
1260
+ # ── LLM-generated scaffold (when user gives a description) ────────────
1261
+ if description and not template:
1262
+ self._scaffold_with_llm(project_name, description, base_dir)
1263
+ return
1264
+
1265
+ # Fallback to blank when no template and no description
1266
+ if template is None:
1267
+ template = "blank"
1268
+
1269
+ # Built-in templates
1270
+ TEMPLATES = {
1271
+ "analysis": {
1272
+ "description": "Stock/asset analysis project",
1273
+ "files": {
1274
+ "main.py": textwrap.dedent("""\
1275
+ #!/usr/bin/env python3
1276
+ \"\"\"
1277
+ {project} — market analysis entry point.
1278
+ Usage: python3 main.py AAPL
1279
+ \"\"\"
1280
+ import sys
1281
+ import os
1282
+ import numpy as np
1283
+ import pandas as pd
1284
+ import yfinance as yf
1285
+ import matplotlib; matplotlib.use('Agg')
1286
+ import matplotlib.pyplot as plt
1287
+ from analysis import run_analysis
1288
+ from report import generate_report
1289
+
1290
+ if __name__ == "__main__":
1291
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "AAPL"
1292
+ data = run_analysis(symbol)
1293
+ generate_report(symbol, data)
1294
+ """),
1295
+ "analysis.py": textwrap.dedent("""\
1296
+ \"\"\"Core analysis logic for {project}.\"\"\"
1297
+ import numpy as np
1298
+ import pandas as pd
1299
+ import yfinance as yf
1300
+
1301
+
1302
+ def run_analysis(symbol: str, period: str = "1y") -> dict:
1303
+ ticker = yf.Ticker(symbol)
1304
+ hist = ticker.history(period=period, auto_adjust=True, progress=False)
1305
+ if hist.empty:
1306
+ raise ValueError(f"No data for {{symbol}}")
1307
+ hist.columns = hist.columns.droplevel(1) if hasattr(hist.columns, 'droplevel') and hist.columns.nlevels > 1 else hist.columns
1308
+ close = hist["Close"]
1309
+ returns = close.pct_change().dropna()
1310
+ sma20 = close.rolling(20).mean()
1311
+ sma50 = close.rolling(50).mean()
1312
+ rsi = _calc_rsi(close)
1313
+ return {{
1314
+ "symbol": symbol,
1315
+ "current_price": round(float(close.iloc[-1]), 2),
1316
+ "sma20": round(float(sma20.iloc[-1]), 2),
1317
+ "sma50": round(float(sma50.iloc[-1]), 2),
1318
+ "rsi": round(float(rsi.iloc[-1]), 1),
1319
+ "annual_return": round(float(returns.mean() * 252), 4),
1320
+ "volatility": round(float(returns.std() * (252 ** 0.5)), 4),
1321
+ "hist": hist,
1322
+ "returns": returns,
1323
+ }}
1324
+
1325
+
1326
+ def _calc_rsi(close: pd.Series, period: int = 14) -> pd.Series:
1327
+ delta = close.diff()
1328
+ gain = delta.clip(lower=0).rolling(period).mean()
1329
+ loss = (-delta.clip(upper=0)).rolling(period).mean()
1330
+ rs = gain / loss.replace(0, float("nan"))
1331
+ return 100 - 100 / (1 + rs)
1332
+ """),
1333
+ "report.py": textwrap.dedent("""\
1334
+ \"\"\"Report generation for {project}.\"\"\"
1335
+ import os
1336
+ import matplotlib; matplotlib.use('Agg')
1337
+ import matplotlib.pyplot as plt
1338
+
1339
+
1340
+ def generate_report(symbol: str, data: dict):
1341
+ fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
1342
+ hist = data["hist"]
1343
+ close = hist["Close"]
1344
+ # Price + SMAs
1345
+ axes[0].plot(close.index, close, label="Close", color="#C08050", linewidth=1.5)
1346
+ axes[0].plot(close.index, hist["Close"].rolling(20).mean(), label="SMA20", color="#2AE8A5", linewidth=1)
1347
+ axes[0].plot(close.index, hist["Close"].rolling(50).mean(), label="SMA50", color="#EF4444", linewidth=1)
1348
+ axes[0].set_title(f"{{symbol}} — Price & Moving Averages", fontsize=14)
1349
+ axes[0].legend(); axes[0].grid(alpha=0.3)
1350
+ # Volume
1351
+ axes[1].bar(hist.index, hist["Volume"], color="#C08050", alpha=0.5, label="Volume")
1352
+ axes[1].set_title("Volume"); axes[1].grid(alpha=0.3)
1353
+ plt.tight_layout()
1354
+ os.makedirs("outputs", exist_ok=True)
1355
+ out = os.path.abspath(os.path.join("outputs", f"{symbol}_analysis.png"))
1356
+ plt.savefig(out, dpi=150, bbox_inches="tight")
1357
+ plt.close()
1358
+ print(f"Chart saved: {{out}}")
1359
+ print(f"Price: ${{data['current_price']}} RSI: {{data['rsi']}} "
1360
+ f"Annual Return: {{data['annual_return']*100:.1f}}% Vol: {{data['volatility']*100:.1f}}%")
1361
+ """),
1362
+ "requirements.txt": "numpy\npandas\nyfinance\nmatplotlib\n",
1363
+ "README.md": textwrap.dedent("""\
1364
+ # {project}
1365
+ Stock analysis project generated by Aria CLI.
1366
+
1367
+ ## Usage
1368
+ ```bash
1369
+ pip3 install -r requirements.txt
1370
+ python3 main.py AAPL
1371
+ ```
1372
+ """),
1373
+ },
1374
+ },
1375
+ "strategy": {
1376
+ "description": "Quant trading strategy with backtesting",
1377
+ "files": {
1378
+ "main.py": textwrap.dedent("""\
1379
+ #!/usr/bin/env python3
1380
+ \"\"\"
1381
+ {project} — backtest entry point.
1382
+ Usage: python3 main.py AAPL 2022-01-01 2024-01-01
1383
+ \"\"\"
1384
+ import sys
1385
+ from strategy import MomentumStrategy
1386
+ from backtest import run_backtest
1387
+
1388
+ if __name__ == "__main__":
1389
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "SPY"
1390
+ start = sys.argv[2] if len(sys.argv) > 2 else "2022-01-01"
1391
+ end = sys.argv[3] if len(sys.argv) > 3 else "2024-01-01"
1392
+ strat = MomentumStrategy(lookback=20)
1393
+ result = run_backtest(strat, symbol, start, end)
1394
+ print(result)
1395
+ """),
1396
+ "strategy.py": textwrap.dedent("""\
1397
+ \"\"\"Strategy definitions for {project}.\"\"\"
1398
+ import pandas as pd
1399
+
1400
+
1401
+ class MomentumStrategy:
1402
+ def __init__(self, lookback: int = 20):
1403
+ self.lookback = lookback
1404
+ self.name = f"Momentum({{lookback}})"
1405
+
1406
+ def generate_signals(self, prices: pd.Series) -> pd.Series:
1407
+ \"\"\"Return +1 (long), -1 (short), 0 (flat) signals.\"\"\"
1408
+ momentum = prices.pct_change(self.lookback)
1409
+ signals = pd.Series(0, index=prices.index)
1410
+ signals[momentum > 0] = 1
1411
+ signals[momentum < 0] = -1
1412
+ return signals.shift(1).fillna(0) # avoid lookahead
1413
+ """),
1414
+ "backtest.py": textwrap.dedent("""\
1415
+ \"\"\"Backtest engine for {project}.\"\"\"
1416
+ import os
1417
+ import numpy as np
1418
+ import pandas as pd
1419
+ import yfinance as yf
1420
+ import matplotlib; matplotlib.use('Agg')
1421
+ import matplotlib.pyplot as plt
1422
+
1423
+
1424
+ def run_backtest(strategy, symbol: str, start: str, end: str) -> dict:
1425
+ ticker = yf.download(symbol, start=start, end=end, auto_adjust=True, progress=False)
1426
+ if ticker.empty:
1427
+ raise ValueError(f"No data for {{symbol}}")
1428
+ prices = ticker["Close"].squeeze()
1429
+ signals = strategy.generate_signals(prices)
1430
+ returns = prices.pct_change().fillna(0)
1431
+ strat_returns = signals * returns
1432
+ equity = (1 + strat_returns).cumprod()
1433
+ bh_equity = (1 + returns).cumprod()
1434
+ # Metrics
1435
+ ann_return = strat_returns.mean() * 252
1436
+ ann_vol = strat_returns.std() * (252 ** 0.5)
1437
+ sharpe = ann_return / ann_vol if ann_vol > 0 else 0
1438
+ max_dd = (equity / equity.cummax() - 1).min()
1439
+ # Plot
1440
+ fig, ax = plt.subplots(figsize=(14, 6))
1441
+ ax.plot(equity.index, equity, label=strategy.name, color="#C08050", linewidth=2)
1442
+ ax.plot(bh_equity.index, bh_equity, label="Buy & Hold", color="#2AE8A5", linewidth=1.5, linestyle="--")
1443
+ ax.set_title(f"{{symbol}} — {{strategy.name}} Backtest"); ax.legend(); ax.grid(alpha=0.3)
1444
+ os.makedirs("outputs", exist_ok=True)
1445
+ out = os.path.abspath(os.path.join("outputs", f"{{symbol}}_backtest.png"))
1446
+ plt.savefig(out, dpi=150, bbox_inches="tight"); plt.close()
1447
+ result = {{
1448
+ "symbol": symbol, "strategy": strategy.name,
1449
+ "ann_return": round(ann_return * 100, 2),
1450
+ "ann_vol": round(ann_vol * 100, 2),
1451
+ "sharpe": round(sharpe, 3),
1452
+ "max_drawdown": round(max_dd * 100, 2),
1453
+ "chart": out,
1454
+ }}
1455
+ print(f"Sharpe: {{result['sharpe']}} Return: {{result['ann_return']}}% "
1456
+ f"MaxDD: {{result['max_drawdown']}}% Chart: {{out}}")
1457
+ return result
1458
+ """),
1459
+ "requirements.txt": "numpy\npandas\nyfinance\nmatplotlib\n",
1460
+ "README.md": textwrap.dedent("""\
1461
+ # {project}
1462
+ Quant strategy backtest project generated by Aria CLI.
1463
+
1464
+ ## Usage
1465
+ ```bash
1466
+ pip3 install -r requirements.txt
1467
+ python3 main.py SPY 2022-01-01 2024-01-01
1468
+ ```
1469
+ """),
1470
+ },
1471
+ },
1472
+ "pipeline": {
1473
+ "description": "Market data pipeline (fetch → process → store)",
1474
+ "files": {
1475
+ "main.py": textwrap.dedent("""\
1476
+ #!/usr/bin/env python3
1477
+ \"\"\"
1478
+ {project} — data pipeline entry point.
1479
+ Usage: python3 main.py AAPL MSFT TSLA
1480
+ \"\"\"
1481
+ import sys
1482
+ from pipeline import DataPipeline
1483
+
1484
+ if __name__ == "__main__":
1485
+ symbols = sys.argv[1:] or ["AAPL", "MSFT", "TSLA"]
1486
+ pipe = DataPipeline(symbols)
1487
+ pipe.run()
1488
+ """),
1489
+ "pipeline.py": textwrap.dedent("""\
1490
+ \"\"\"Data pipeline for {project}.\"\"\"
1491
+ import os
1492
+ import pandas as pd
1493
+ import yfinance as yf
1494
+
1495
+
1496
+ class DataPipeline:
1497
+ def __init__(self, symbols: list, period: str = "1y", output_dir: str = "data"):
1498
+ self.symbols = symbols
1499
+ self.period = period
1500
+ self.output_dir = os.path.expanduser(output_dir)
1501
+ os.makedirs(self.output_dir, exist_ok=True)
1502
+
1503
+ def fetch(self, symbol: str) -> pd.DataFrame:
1504
+ df = yf.download(symbol, period=self.period, auto_adjust=True, progress=False)
1505
+ df.columns = df.columns.droplevel(1) if df.columns.nlevels > 1 else df.columns
1506
+ return df
1507
+
1508
+ def process(self, df: pd.DataFrame) -> pd.DataFrame:
1509
+ df = df.copy()
1510
+ df["Returns"] = df["Close"].pct_change()
1511
+ df["SMA20"] = df["Close"].rolling(20).mean()
1512
+ df["SMA50"] = df["Close"].rolling(50).mean()
1513
+ df["Volatility"] = df["Returns"].rolling(20).std() * (252 ** 0.5)
1514
+ return df.dropna()
1515
+
1516
+ def store(self, symbol: str, df: pd.DataFrame):
1517
+ path = os.path.join(self.output_dir, f"{{symbol}}.csv")
1518
+ df.to_csv(path)
1519
+ print(f" Saved {{len(df)}} rows → {{path}}")
1520
+
1521
+ def run(self):
1522
+ print(f"Running pipeline for: {{self.symbols}}")
1523
+ for symbol in self.symbols:
1524
+ try:
1525
+ raw = self.fetch(symbol)
1526
+ processed = self.process(raw)
1527
+ self.store(symbol, processed)
1528
+ except Exception as e:
1529
+ print(f" Error {{symbol}}: {{e}}")
1530
+ print("Pipeline complete.")
1531
+ """),
1532
+ "requirements.txt": "pandas\nyfinance\n",
1533
+ "README.md": textwrap.dedent("""\
1534
+ # {project}
1535
+ Market data pipeline generated by Aria CLI.
1536
+
1537
+ ## Usage
1538
+ ```bash
1539
+ pip3 install -r requirements.txt
1540
+ python3 main.py AAPL MSFT TSLA
1541
+ # Output CSVs saved to ./data/
1542
+ ```
1543
+ """),
1544
+ },
1545
+ },
1546
+ "blank": {
1547
+ "description": "Blank project scaffold",
1548
+ "files": {
1549
+ "main.py": textwrap.dedent("""\
1550
+ #!/usr/bin/env python3
1551
+ \"\"\"
1552
+ {project} — main entry point.
1553
+ \"\"\"
1554
+ import os
1555
+ import sys
1556
+ import numpy as np
1557
+ import pandas as pd
1558
+
1559
+
1560
+ def main():
1561
+ print("Hello from {project}!")
1562
+
1563
+
1564
+ if __name__ == "__main__":
1565
+ main()
1566
+ """),
1567
+ "requirements.txt": "numpy\npandas\n",
1568
+ "README.md": "# {project}\n\nProject generated by Aria CLI.\n",
1569
+ },
1570
+ },
1571
+ }
1572
+
1573
+ if template not in TEMPLATES:
1574
+ msg = f"Unknown template '{template}'. Available: {', '.join(TEMPLATES)}"
1575
+ self.context.console.print(f"[red]{msg}[/red]" if self.context.has_rich else msg)
1576
+ return
1577
+
1578
+ tmpl = TEMPLATES[template]
1579
+ files = {
1580
+ k: v.format(project=project_name) if isinstance(v, str) else v
1581
+ for k, v in tmpl["files"].items()
1582
+ }
1583
+
1584
+ # ── Preview: show tree + file summaries ──────────────────────────────
1585
+ if self.context.has_rich:
1586
+ self.context.console.print()
1587
+ self.context.console.print(f" [bold]Scaffold:[/bold] [cyan]{project_name}[/cyan] "
1588
+ f"[dim]({tmpl['description']}, {template} template)[/dim]")
1589
+ self.context.console.print(f" [dim]Location:[/dim] {base_dir}")
1590
+ self.context.console.print()
1591
+ self.context.console.print(f" [dim]{base_dir.name}/[/dim]")
1592
+ for fname, fcontent in files.items():
1593
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1594
+ exists_tag = " [yellow](exists)[/yellow]" if (base_dir / fname).exists() else ""
1595
+ self.context.console.print(f" [dim] ├── {fname:<24s}[/dim] {lines} lines{exists_tag}")
1596
+ self.context.console.print()
1597
+ else:
1598
+ print(f"\nScaffold: {project_name} ({template} template)")
1599
+ print(f"Location: {base_dir}")
1600
+ print(f"\n {base_dir.name}/")
1601
+ for fname, fcontent in files.items():
1602
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1603
+ exists_tag = " (exists)" if (base_dir / fname).exists() else ""
1604
+ print(f" ├── {fname:<24s} {lines} lines{exists_tag}")
1605
+ print()
1606
+
1607
+ # ── Ask: approve all / approve each / cancel ─────────────────────────
1608
+ # In non-interactive mode (-p flag / piped stdin) auto-approve all files.
1609
+ if not sys.stdin.isatty():
1610
+ choice = "y"
1611
+ self.context.console.print(" [dim](非交互模式:自动确认创建所有文件)[/dim]") if self.context.has_rich else print(" (Auto-approved: non-interactive mode)")
1612
+ elif self.context.has_rich:
1613
+ choice = self.context.console.input(
1614
+ " [bold]Create these files?[/bold] "
1615
+ "[dim]\\[y=all / n=cancel / r=review each][/dim] "
1616
+ ).strip().lower()
1617
+ else:
1618
+ choice = input(" Create these files? [y=all / n=cancel / r=review each] ").strip().lower()
1619
+
1620
+ if choice in ("n", "no"):
1621
+ self.context.console.print("[dim]Scaffold cancelled.[/dim]" if self.context.has_rich else "Cancelled.")
1622
+ return
1623
+
1624
+ approve_each = choice in ("r", "review")
1625
+ created, skipped = [], []
1626
+
1627
+ for fname, fcontent in files.items():
1628
+ target = base_dir / fname
1629
+ if approve_each:
1630
+ if self.context.has_rich:
1631
+ self.context.console.print(f"\n [dim]{fname}[/dim] ({fcontent.count(chr(10))+1} lines)")
1632
+ sub = self.context.console.input(
1633
+ " [dim]Write this file? [y/n] [/dim]"
1634
+ ).strip().lower()
1635
+ else:
1636
+ print(f"\n {fname} ({fcontent.count(chr(10))+1} lines)")
1637
+ sub = input(" Write? [y/n] ").strip().lower()
1638
+ if sub not in ("y", "yes", ""):
1639
+ skipped.append(fname)
1640
+ continue
1641
+
1642
+ result = _tool_write_file({"path": str(target), "content": fcontent, "_skip_confirm": True})
1643
+ if result["success"]:
1644
+ created.append(fname)
1645
+ else:
1646
+ err = result.get("error", "?")
1647
+ if self.context.has_rich:
1648
+ self.context.console.print(f" [red]Failed {fname}: {err}[/red]")
1649
+ else:
1650
+ print(f" Failed {fname}: {err}")
1651
+
1652
+ # ── Summary ───────────────────────────────────────────────────────────
1653
+ if self.context.has_rich:
1654
+ self.context.console.print()
1655
+ if created:
1656
+ self.context.console.print(f" [green]✓[/green] Created {len(created)} file(s) in [bold]{base_dir}[/bold]")
1657
+ for f in created:
1658
+ self.context.console.print(f" [dim]{f}[/dim]")
1659
+ if skipped:
1660
+ self.context.console.print(f" [dim]Skipped: {', '.join(skipped)}[/dim]")
1661
+ self.context.console.print()
1662
+ self.context.console.print(f" [dim]Run: cd \"{base_dir}\" && python3 main.py[/dim]")
1663
+ self.context.console.print()
1664
+ else:
1665
+ print(f"\nCreated {len(created)} files in {base_dir}")
1666
+ if skipped:
1667
+ print(f"Skipped: {', '.join(skipped)}")
1668
+ print(f"Run: cd \"{base_dir}\" && python3 main.py")
1669
+
1670
+ async def _strategy_overview(self, vault):
1671
+ """所有策略一览看板:版本数 / 最新 / 回测 Sharpe·收益 / 审查 / 是否部署实盘。"""
1672
+
1673
+ names = vault.list_all_names()
1674
+ if not names:
1675
+ self.context.console.print(" [dim]还没有保存任何策略。用 /strategy save 开始。[/dim]" if self.context.has_rich
1676
+ else "no strategies")
1677
+ return
1678
+ deployed = set()
1679
+ try:
1680
+ from portfolio_ledger import PortfolioLedger
1681
+ groups = PortfolioLedger().positions_by_strategy(names)
1682
+ deployed = {k for k in groups if k != PortfolioLedger.UNATTRIBUTED}
1683
+ except Exception:
1684
+ pass
1685
+
1686
+ if self.context.has_rich:
1687
+ from rich.table import Table
1688
+ self.context.console.print()
1689
+ self.context.console.print(" [bold cyan]策略总览[/bold cyan]")
1690
+ tbl = Table(box=None, header_style="bold", pad_edge=False)
1691
+ tbl.add_column("策略", width=20)
1692
+ tbl.add_column("版本", justify="right", width=4)
1693
+ tbl.add_column("最新", width=8)
1694
+ tbl.add_column("回测", width=22)
1695
+ tbl.add_column("审查", justify="center", width=4)
1696
+ tbl.add_column("实盘", justify="center", width=4)
1697
+ for nm in names:
1698
+ vers = vault.list(nm, limit=50)
1699
+ if not vers:
1700
+ continue
1701
+ latest = vers[0]
1702
+ btv = next((v for v in vers if v.backtest_result), None)
1703
+ if btv and btv.backtest_result:
1704
+ br = btv.backtest_result
1705
+ sh = br.get("sharpe_ratio")
1706
+ rt = br.get("total_return_pct", br.get("total_return"))
1707
+ if rt is not None:
1708
+ rt = rt * 100 if abs(rt) < 5 else rt # 兼容 0.18 / 18.0
1709
+ if sh is not None and rt is not None:
1710
+ body = f"Sharpe {sh:.2f} · {rt:+.1f}%"
1711
+ elif sh is not None:
1712
+ body = f"Sharpe {sh:.2f}"
1713
+ else:
1714
+ body = "—"
1715
+ col = "green" if (sh or 0) >= 1 else ("yellow" if (sh or 0) > 0 else "red")
1716
+ bt = f"[{col}]{body}[/{col}]"
1717
+ else:
1718
+ bt = "[dim]未回测[/dim]"
1719
+ rv = "[green]✓[/green]" if latest.review_result else "[dim]—[/dim]"
1720
+ live = "[green]●[/green]" if nm in deployed else "[dim]○[/dim]"
1721
+ tbl.add_row(nm[:20], str(len(vers)), latest.version_tag, bt, rv, live)
1722
+ self.context.console.print(tbl)
1723
+ self.context.console.print(" [dim]详情: /strategy show <名字> · 部署: /deploy <名字> SYM:qty[/dim]")
1724
+ self.context.console.print()
1725
+ else:
1726
+ for nm in names:
1727
+ vers = vault.list(nm, limit=50)
1728
+ if vers:
1729
+ print(f" {nm}: {len(vers)} versions, latest {vers[0].version_tag}"
1730
+ + (" [deployed]" if nm in deployed else ""))
1731
+
1732
+ @staticmethod
1733
+ def _parse_deploy_token(tok: str):
1734
+ """
1735
+ 解析部署持仓 token → (symbol, qty_or_None, weight_or_None, price_or_None)。
1736
+
1737
+ SYM:qty[@price] 固定股数 e.g. AAPL:10 AAPL:10@150
1738
+ SYM:pct%[@price] 占资金比例(权重) e.g. AAPL:30% AAPL:30%@150
1739
+
1740
+ 价格可省(返回 None,由调用方取实时价);权重模式由调用方结合资金折算股数。
1741
+ 非法输入抛 ValueError。作为 staticmethod 经 self 调用,以在 mixin 全局重绑后仍可用。
1742
+ """
1743
+ sym, sep, rest = tok.partition(":")
1744
+ if not sep or not rest.strip():
1745
+ raise ValueError("缺数量,用 SYM:qty 或 SYM:pct%")
1746
+ amt_s, _, px_s = rest.partition("@")
1747
+ amt_s = amt_s.strip()
1748
+ price = float(px_s) if px_s.strip() else None
1749
+ if price is not None and price <= 0:
1750
+ raise ValueError("价格须为正")
1751
+ sym = sym.strip().upper()
1752
+ if amt_s.endswith("%"):
1753
+ weight = float(amt_s[:-1]) / 100.0
1754
+ if not (0 < weight <= 1.0):
1755
+ raise ValueError("权重须在 0–100%")
1756
+ return sym, None, weight, price
1757
+ qty = float(amt_s)
1758
+ if qty <= 0:
1759
+ raise ValueError("数量须为正")
1760
+ return sym, qty, None, price
1761
+
1762
+ @staticmethod
1763
+ def _value_weights(positions, prices):
1764
+ """市值权重:{sym: net_qty} + {sym: price} → {sym: weight}(总市值为 0 或无价时返回空)。"""
1765
+ vals = {s: positions[s] * prices[s] for s in positions if prices.get(s)}
1766
+ tot = sum(vals.values())
1767
+ return {s: v / tot for s, v in vals.items()} if tot > 0 else {}
1768
+
1769
+ @staticmethod
1770
+ def _rebalance_plan(current, targets, prices, capital=None):
1771
+ """
1772
+ 计算把现有持仓对齐到目标权重所需的调仓(纯函数,可测)。
1773
+
1774
+ current: {sym: net_qty} targets: {sym: weight} prices: {sym: price}
1775
+ capital: 组合总值;省略则用现有持仓市值之和(在现有资金内再平衡)。
1776
+
1777
+ 返回按标的排序的计划项 list:{symbol, cur_weight, target_weight, side, shares, price}。
1778
+ 只产生非平凡调仓;不做空(SELL 数量上限为当前持仓)。
1779
+ """
1780
+ total = capital if capital else sum(current.get(s, 0.0) * prices[s] for s in prices if s in current)
1781
+ if not total or total <= 0:
1782
+ return []
1783
+ plan = []
1784
+ for s in sorted(set(current) | set(targets)):
1785
+ px = prices.get(s)
1786
+ if not px:
1787
+ continue
1788
+ cq = current.get(s, 0.0)
1789
+ cw = (cq * px) / total
1790
+ tw = targets.get(s, 0.0)
1791
+ dshares = round((tw * total - cq * px) / px, 4)
1792
+ if abs(dshares) < 1e-4:
1793
+ continue
1794
+ side = "BUY" if dshares > 0 else "SELL"
1795
+ shares = abs(dshares)
1796
+ if side == "SELL":
1797
+ shares = min(shares, cq) # 不做空:卖出不超过持仓
1798
+ if shares < 1e-4:
1799
+ continue
1800
+ plan.append({"symbol": s, "cur_weight": cw, "target_weight": tw,
1801
+ "side": side, "shares": round(shares, 4), "price": px})
1802
+ return plan
1803
+
1804
+ async def cmd_deploy(self, args: str):
1805
+ """
1806
+ 部署策略到实盘账本,闭合 回测 → 实盘 环。
1807
+
1808
+ /deploy <策略> AAPL:10 MSFT:5@320 — 按股数建仓(@价格可省,省则取实时价)
1809
+ /deploy <策略> $100000 AAPL:30% MSFT:20% — 按权重建仓(先给资金,自动按实时价折算股数)
1810
+ /deploy <策略> rebalance [apply] AAPL:30% MSFT:20% — 对齐到目标权重(默认预览,apply 落账)
1811
+ /deploy <策略> rebalance equal | like <参考策略> — 等权 / 对齐另一策略的市值权重
1812
+ /deploy <策略> close — 平掉该策略当前实盘净持仓
1813
+ /deploy <策略> — 显示该策略回测摘要与用法
1814
+
1815
+ 交易自动打标记 reason="deploy <策略> @<版本>",从而被
1816
+ /strategy show(实盘 vs 回测)与 /portfolio holdings(分组看板)关联。
1817
+ """
1818
+
1819
+ if not _get__HAS_VAULT():
1820
+ self.context.console.print("[yellow]strategy_vault.py 未找到[/yellow]" if self.context.has_rich else "strategy_vault not found")
1821
+ return
1822
+ parts = args.strip().split()
1823
+ if not parts:
1824
+ msg = ("用法: /deploy <策略> SYM:qty[@price] … | /deploy <策略> $资金 SYM:pct% … | "
1825
+ "/deploy <策略> close")
1826
+ self.context.console.print(f" [yellow]{msg}[/yellow]" if self.context.has_rich else msg)
1827
+ return
1828
+
1829
+ def _live_price(sym):
1830
+ try:
1831
+ import yfinance as yf
1832
+ h = yf.Ticker(sym).history(period="1d")
1833
+ return float(h["Close"].iloc[-1]) if not h.empty else None
1834
+ except Exception:
1835
+ return None
1836
+
1837
+ name = parts[0]
1838
+ vault = _get_vault()
1839
+ all_versions = vault.list(name, limit=20)
1840
+ if not all_versions:
1841
+ self.context.console.print(f" [red]未找到策略 '{name}'[/red],先 /strategy save。" if self.context.has_rich
1842
+ else f"Not found: {name}")
1843
+ return
1844
+ latest = all_versions[0]
1845
+ bt = latest.backtest_result or next((v.backtest_result for v in all_versions if v.backtest_result), None)
1846
+
1847
+ from portfolio_ledger import PortfolioLedger
1848
+ ledger = PortfolioLedger()
1849
+ rest = parts[1:]
1850
+
1851
+ # /deploy <策略> → 回测摘要 + 用法(你将对照它部署)
1852
+ if not rest:
1853
+ if self.context.has_rich:
1854
+ self.context.console.print(f"\n [bold cyan]部署 · {name}[/bold cyan] [dim]最新 {latest.version_tag} · {latest.created_at[:10]}[/dim]")
1855
+ if bt:
1856
+ def _g(*ks):
1857
+ for k in ks:
1858
+ if bt.get(k) is not None:
1859
+ return bt[k]
1860
+ return None
1861
+ sh = _g("sharpe_ratio"); rt = _g("total_return_pct", "total_return")
1862
+ self.context.console.print(f" [dim]回测: Sharpe {sh if sh is not None else '—'} · "
1863
+ f"总收益 {rt if rt is not None else '—'}[/dim]")
1864
+ else:
1865
+ self.context.console.print(" [dim]该策略尚无回测。建议先 /backtest 再部署。[/dim]")
1866
+ self.context.console.print(" [dim]建仓: /deploy {0} AAPL:10 MSFT:5@320 平仓: /deploy {0} close[/dim]\n".format(name))
1867
+ else:
1868
+ print(f"deploy {name} (latest {latest.version_tag}); usage: /deploy {name} SYM:qty[@price] | close")
1869
+ return
1870
+
1871
+ # /deploy <策略> close → 平掉该策略净持仓
1872
+ if rest[0].lower() == "close":
1873
+ poss = ledger.positions_by_strategy([name]).get(name, [])
1874
+ if not poss:
1875
+ self.context.console.print(f" [dim]{name} 当前无实盘净持仓。[/dim]" if self.context.has_rich else "no positions")
1876
+ return
1877
+ done = []
1878
+ for p in poss:
1879
+ px = _live_price(p["symbol"]) or p["avg_cost"]
1880
+ tid = ledger.add_trade(p["symbol"], "SELL", p["net_qty"], px,
1881
+ reason=f"deploy {name} close @{latest.version_tag}")
1882
+ done.append((tid, p["symbol"], p["net_qty"], px))
1883
+ if self.context.has_rich:
1884
+ self.context.console.print(f"\n [green]✓ 已平仓 {name}[/green] [dim]{len(done)} 笔[/dim]")
1885
+ for tid, s, q, px in done:
1886
+ self.context.console.print(f" [red]SELL[/red] {s} × {q:g} @ {px:,.2f} [dim]#{tid}[/dim]")
1887
+ self.context.console.print(" [dim]撤销: /journal delete <id>[/dim]\n")
1888
+ else:
1889
+ for tid, s, q, px in done:
1890
+ print(f" SELL {s} {q} @ {px} #{tid}")
1891
+ return
1892
+
1893
+ # /deploy <策略> rebalance [apply] [$资金] SYM:pct% … → 对齐到目标权重(默认预览)
1894
+ if rest[0].lower() == "rebalance":
1895
+ sub = rest[1:]
1896
+ do_apply = bool(sub and sub[0].lower() == "apply")
1897
+ if do_apply:
1898
+ sub = sub[1:]
1899
+ # 剥离资金 token($100000 / cap:100000)
1900
+ capital, errs, rest2 = None, [], []
1901
+ for tok in sub:
1902
+ t = tok.strip()
1903
+ if t.startswith("$") or t.lower().startswith(("cap:", "cap=")):
1904
+ cs = t[1:] if t.startswith("$") else t[4:]
1905
+ try:
1906
+ capital = float(cs.replace(",", ""))
1907
+ except ValueError:
1908
+ pass
1909
+ continue
1910
+ rest2.append(tok)
1911
+
1912
+ cur = {p["symbol"]: p for p in ledger.positions_by_strategy([name]).get(name, [])}
1913
+ mode = rest2[0].lower() if rest2 else ""
1914
+
1915
+ # 目标权重来源:equal(当前持仓等权)/ like <策略>(对齐另一策略市值权重)/ 显式 SYM:pct%
1916
+ targets = {} # sym -> (weight, price_or_None)
1917
+ if mode == "equal":
1918
+ if not cur:
1919
+ self.context.console.print(f" [yellow]{name} 无持仓,无法等权再平衡。[/yellow]" if self.context.has_rich else "no positions")
1920
+ return
1921
+ w = 1.0 / len(cur)
1922
+ targets = {s: (w, None) for s in cur}
1923
+ elif mode == "like":
1924
+ if len(rest2) < 2:
1925
+ self.context.console.print(f" [yellow]用法: /deploy {name} rebalance like <参考策略>[/yellow]"
1926
+ if self.context.has_rich else "need ref strategy")
1927
+ return
1928
+ ref = rest2[1]
1929
+ ref_pos = {p["symbol"]: p["net_qty"]
1930
+ for p in ledger.positions_by_strategy([ref]).get(ref, [])}
1931
+ if not ref_pos:
1932
+ self.context.console.print(f" [yellow]参考策略 '{ref}' 无实盘持仓。[/yellow]" if self.context.has_rich else "ref empty")
1933
+ return
1934
+ refw = self._value_weights(ref_pos, {s: (_live_price(s) or 0) for s in ref_pos})
1935
+ if not refw:
1936
+ self.context.console.print(f" [yellow]无法获取 '{ref}' 的报价以计算参考权重。[/yellow]"
1937
+ if self.context.has_rich else "no ref prices")
1938
+ return
1939
+ targets = {s: (w, None) for s, w in refw.items()}
1940
+ else:
1941
+ for tok in rest2:
1942
+ try:
1943
+ s, q, w, px = self._parse_deploy_token(tok)
1944
+ if w is None:
1945
+ errs.append(f"{tok} (再平衡用权重 SYM:pct%)"); continue
1946
+ targets[s] = (w, px)
1947
+ except Exception as e:
1948
+ errs.append(f"{tok} ({e})")
1949
+ if not targets:
1950
+ self.context.console.print(" [yellow]需要目标权重:SYM:pct% … | equal | like <策略>[/yellow]"
1951
+ if self.context.has_rich else "need targets")
1952
+ return
1953
+ syms = sorted(set(cur) | set(targets))
1954
+ prices = {}
1955
+ for s in syms:
1956
+ px = (targets.get(s, (None, None))[1] or _live_price(s)
1957
+ or (cur[s]["avg_cost"] if s in cur else None))
1958
+ if px:
1959
+ prices[s] = px
1960
+ plan = self._rebalance_plan({s: cur[s]["net_qty"] for s in cur},
1961
+ {s: w for s, (w, _) in targets.items()}, prices, capital)
1962
+ if not plan:
1963
+ self.context.console.print(f" [green]{name} 已接近目标权重,无需调仓。[/green]" if self.context.has_rich else "balanced")
1964
+ return
1965
+ if self.context.has_rich:
1966
+ from rich.table import Table
1967
+ self.context.console.print()
1968
+ head = "执行再平衡" if do_apply else "再平衡预览"
1969
+ self.context.console.print(f" [bold cyan]{name} · {head}[/bold cyan]")
1970
+ tbl = Table(box=None, header_style="bold", pad_edge=False)
1971
+ tbl.add_column("标的", width=8)
1972
+ tbl.add_column("当前%", justify="right", width=7)
1973
+ tbl.add_column("目标%", justify="right", width=7)
1974
+ tbl.add_column("操作", width=6)
1975
+ tbl.add_column("股数", justify="right", width=10)
1976
+ tbl.add_column("金额", justify="right", width=12)
1977
+ for it in plan:
1978
+ sc = "green" if it["side"] == "BUY" else "red"
1979
+ tbl.add_row(it["symbol"], f"{it['cur_weight']*100:.0f}%", f"{it['target_weight']*100:.0f}%",
1980
+ f"[{sc}]{it['side']}[/{sc}]", f"{it['shares']:g}",
1981
+ f"{it['shares']*it['price']:,.0f}")
1982
+ self.context.console.print(tbl)
1983
+ for e in errs:
1984
+ self.context.console.print(f" [yellow]跳过 {e}[/yellow]")
1985
+ if do_apply:
1986
+ ids = [ledger.add_trade(it["symbol"], it["side"], it["shares"], it["price"],
1987
+ reason=f"deploy {name} rebalance @{latest.version_tag}")
1988
+ for it in plan]
1989
+ self.context.console.print(f" [green]✓ 已执行 {len(ids)} 笔[/green] · [dim]撤销 /journal delete <id>[/dim]")
1990
+ else:
1991
+ self.context.console.print(f" [dim]以上为预览。执行: /deploy {name} rebalance apply …(同样参数)[/dim]")
1992
+ self.context.console.print()
1993
+ else:
1994
+ for it in plan:
1995
+ print(f" {it['side']} {it['symbol']} {it['shares']:g} @ {it['price']}")
1996
+ if do_apply:
1997
+ for it in plan:
1998
+ ledger.add_trade(it["symbol"], it["side"], it["shares"], it["price"],
1999
+ reason=f"deploy {name} rebalance @{latest.version_tag}")
2000
+ print(f" applied {len(plan)} trades")
2001
+ return
2002
+
2003
+ # /deploy <策略> [$资金] SYM:qty|SYM:pct% [@price] … → 建仓
2004
+ # 先剥离资金 token($100000 / cap:100000),用于按权重折算股数
2005
+ capital = None
2006
+ pos_tokens = []
2007
+ for tok in rest:
2008
+ t = tok.strip()
2009
+ if t.startswith("$") or t.lower().startswith(("cap:", "cap=")):
2010
+ cs = t[1:] if t.startswith("$") else t[4:]
2011
+ try:
2012
+ capital = float(cs.replace(",", ""))
2013
+ except ValueError:
2014
+ pass
2015
+ continue
2016
+ pos_tokens.append(tok)
2017
+
2018
+ deployed, errors, total_weight = [], [], 0.0
2019
+ for tok in pos_tokens:
2020
+ try:
2021
+ sym, qty, weight, price = self._parse_deploy_token(tok)
2022
+ if price is None:
2023
+ price = _live_price(sym)
2024
+ if not price:
2025
+ errors.append(f"{sym} (取不到价,加 @price)"); continue
2026
+ if weight is not None:
2027
+ if not capital:
2028
+ errors.append(f"{sym} (按权重部署需先给资金,如 $100000)"); continue
2029
+ total_weight += weight
2030
+ qty = round(capital * weight / price, 4)
2031
+ tid = ledger.add_trade(sym, "BUY", qty, price,
2032
+ reason=f"deploy {name} @{latest.version_tag}")
2033
+ deployed.append((tid, sym, qty, price))
2034
+ except Exception as e:
2035
+ errors.append(f"{tok} ({e})")
2036
+ if total_weight > 1.0001:
2037
+ errors.append(f"权重合计 {total_weight*100:.0f}% > 100%(已按各自比例建仓,注意超配)")
2038
+
2039
+ if self.context.has_rich:
2040
+ self.context.console.print(f"\n [bold cyan]部署 {name} {latest.version_tag} → 实盘[/bold cyan]")
2041
+ total = 0.0
2042
+ for tid, s, q, px in deployed:
2043
+ total += q * px
2044
+ self.context.console.print(f" [green]BUY[/green] {s} × {q:g} @ {px:,.2f} = {q*px:,.0f} [dim]#{tid}[/dim]")
2045
+ if deployed:
2046
+ self.context.console.print(f" [dim]共投入 {total:,.0f} · 标记 reason='deploy {name} @{latest.version_tag}'[/dim]")
2047
+ self.context.console.print(f" [dim]→ /strategy show {name} 看实盘 vs 回测 · /portfolio holdings 看分组看板[/dim]")
2048
+ for e in errors:
2049
+ self.context.console.print(f" [yellow]跳过 {e}[/yellow]")
2050
+ self.context.console.print()
2051
+ else:
2052
+ for tid, s, q, px in deployed:
2053
+ print(f" BUY {s} {q} @ {px} #{tid}")
2054
+ for e in errors:
2055
+ print(f" skip {e}")
2056
+
2057
+ async def cmd_strategy(self, args: str):
2058
+ """
2059
+ 策略版本管理系统 (Strategy Vault)
2060
+
2061
+ /strategy save [name] [message] — 保存当前对话中最后一段代码
2062
+ /strategy list [name] — 列出所有版本
2063
+ /strategy diff [name] [v1] [v2] — 查看版本差异
2064
+ /strategy load [name] [tag/id] — 加载版本到上下文
2065
+ /strategy review — AI审查+静态检测
2066
+ """
2067
+
2068
+ if not _get__HAS_VAULT():
2069
+ self.context.console.print(" [yellow]strategy_vault.py 未找到[/yellow]" if self.context.has_rich
2070
+ else " strategy_vault.py not found")
2071
+ return
2072
+
2073
+ parts = args.strip().split(None, 3)
2074
+ sub = parts[0].lower() if parts else "list"
2075
+
2076
+ vault = _get_vault()
2077
+
2078
+ # ── save ──────────────────────────────────────────────────────────
2079
+ if sub == "save":
2080
+ # 从对话历史中提取最后一段 Python 代码
2081
+ code = self._extract_last_code()
2082
+ if not code:
2083
+ if self.context.has_rich:
2084
+ self.context.console.print(" [yellow]未在对话中找到代码块。先让 Aria 生成策略代码。[/yellow]")
2085
+ else:
2086
+ print(" No code found in conversation. Generate strategy code first.")
2087
+ return
2088
+ name = parts[1] if len(parts) > 1 and not parts[1].startswith('"') else "strategy"
2089
+ message = " ".join(parts[2:]).strip('"') if len(parts) > 2 else ""
2090
+ sv = vault.save(code, name=name, message=message)
2091
+ if self.context.has_rich:
2092
+ self.context.console.print(
2093
+ f"\n [green]✓[/green] 策略已保存 "
2094
+ f"[bold]{sv.name}[/bold] [dim]{sv.version_tag}[/dim] "
2095
+ f"hash={sv.code_hash} {sv.created_at[:16]}"
2096
+ )
2097
+ else:
2098
+ print(f" Saved: {sv.name} {sv.version_tag} ({sv.created_at[:16]})")
2099
+
2100
+ # ── list ──────────────────────────────────────────────────────────
2101
+ elif sub == "list":
2102
+ name = parts[1] if len(parts) > 1 else None
2103
+ if name:
2104
+ versions = vault.list(name)
2105
+ title = f" 策略: {name}"
2106
+ else:
2107
+ # Show all strategies
2108
+ all_names = vault.list_all_names()
2109
+ if not all_names:
2110
+ self.context.console.print(" [dim]策略金库为空。使用 /strategy save 保存策略。[/dim]" if self.context.has_rich
2111
+ else " Vault is empty.")
2112
+ return
2113
+ if self.context.has_rich:
2114
+ self.context.console.print("\n [bold]策略金库[/bold]\n")
2115
+ for n in all_names:
2116
+ vs = vault.list(n, limit=3)
2117
+ latest = vs[0] if vs else None
2118
+ if latest:
2119
+ bt = ""
2120
+ if latest.backtest_result:
2121
+ br = latest.backtest_result
2122
+ bt = f" sharpe={br.get('sharpe_ratio','?')} ret={br.get('total_return_pct','?')}%"
2123
+ self.context.console.print(
2124
+ f" [bold]{n}[/bold] [dim]{len(vs)}个版本 "
2125
+ f"最新:{latest.version_tag} {latest.created_at[:10]}{bt}[/dim]"
2126
+ )
2127
+ self.context.console.print()
2128
+ else:
2129
+ for n in all_names:
2130
+ print(f" {n}")
2131
+ return
2132
+ if not versions:
2133
+ self.context.console.print(f" [dim]没有找到策略 '{name}'[/dim]" if self.context.has_rich else f" Not found: {name}")
2134
+ return
2135
+ if self.context.has_rich:
2136
+ self.context.console.print(f"\n [bold]{title}[/bold]\n")
2137
+ for v in versions:
2138
+ bt = ""
2139
+ if v.backtest_result:
2140
+ br = v.backtest_result
2141
+ sharpe = br.get("sharpe_ratio")
2142
+ ret = br.get("total_return_pct")
2143
+ bt = f" [green]sharpe={sharpe:.2f} ret={ret:.1f}%[/green]" if sharpe else ""
2144
+ reviewed = " [dim]✓reviewed[/dim]" if v.review_result else ""
2145
+ msg = f" [dim]{v.message[:50]}[/dim]" if v.message else ""
2146
+ self.context.console.print(
2147
+ f" [dim]{v.id:4d}[/dim] [bold]{v.version_tag}[/bold] "
2148
+ f"[dim]{v.created_at[:16]}[/dim]{msg}{bt}{reviewed}"
2149
+ )
2150
+ self.context.console.print()
2151
+ else:
2152
+ for v in versions:
2153
+ print(v.summary_line())
2154
+
2155
+ # ── show: 统一策略工作台 (版本史 + 回测 + 实盘持仓) ──────────────────
2156
+ elif sub == "show":
2157
+ # 不带名字 → 所有策略总览看板
2158
+ if len(parts) <= 1:
2159
+ await self._strategy_overview(vault)
2160
+ return
2161
+ name = parts[1]
2162
+ versions = vault.list(name, limit=20)
2163
+ if not versions:
2164
+ self.context.console.print(f" [dim]没有找到策略 '{name}'。用 /strategy list 查看全部。[/dim]" if self.context.has_rich
2165
+ else f" Not found: {name}")
2166
+ return
2167
+ latest = versions[0]
2168
+ bt_ver = next((v for v in versions if v.backtest_result), None)
2169
+
2170
+ def _num(d, *keys):
2171
+ for k in keys:
2172
+ val = d.get(k)
2173
+ if val is not None:
2174
+ return val
2175
+ return None
2176
+
2177
+ def _pct(x):
2178
+ if x is None:
2179
+ return "—"
2180
+ v = x * 100 if abs(x) < 5 else x # 兼容 0.18 与 18.0 两种存法
2181
+ return f"{v:+.1f}%"
2182
+
2183
+ def _spark(curve):
2184
+ vals = []
2185
+ for x in (curve or []):
2186
+ if isinstance(x, (int, float)):
2187
+ vals.append(float(x))
2188
+ elif isinstance(x, dict):
2189
+ for k in ("equity", "value", "nav", "total", "close"):
2190
+ if x.get(k) is not None:
2191
+ vals.append(float(x[k])); break
2192
+ if len(vals) < 2:
2193
+ return ""
2194
+ blocks = "▁▂▃▄▅▆▇█"
2195
+ s = vals[:: max(1, len(vals) // 48)]
2196
+ lo, hi = min(s), max(s)
2197
+ if hi <= lo:
2198
+ return blocks[0] * len(s)
2199
+ return "".join(blocks[int((v - lo) / (hi - lo) * 7)] for v in s)
2200
+
2201
+ if self.context.has_rich:
2202
+ self.context.console.print()
2203
+ reviewed = "✓ 已审查" if latest.review_result else "未审查"
2204
+ self.context.console.print(f" [bold cyan]策略工作台 · {name}[/bold cyan]")
2205
+ self.context.console.print(f" [dim]最新 {latest.version_tag} · {latest.created_at[:16]} · "
2206
+ f"{len(versions)} 个版本 · {reviewed} · hash {latest.code_hash}[/dim]")
2207
+ if latest.message:
2208
+ self.context.console.print(f" [dim]“{latest.message[:70]}”[/dim]")
2209
+
2210
+ self.context.console.print("\n [bold]版本史[/bold]")
2211
+ for v in versions[:6]:
2212
+ br = v.backtest_result or {}
2213
+ sh = _num(br, "sharpe_ratio")
2214
+ rt = _num(br, "total_return_pct", "total_return")
2215
+ mtxt = f" [green]Sharpe {sh:.2f} · {_pct(rt)}[/green]" if sh is not None else ""
2216
+ marker = "●" if v is latest else "○"
2217
+ msg = f" [dim]{v.message[:42]}[/dim]" if v.message else ""
2218
+ self.context.console.print(f" [cyan]{marker}[/cyan] [bold]{v.version_tag}[/bold] "
2219
+ f"[dim]{v.created_at[:10]}[/dim]{mtxt}{msg}")
2220
+
2221
+ self.context.console.print("\n [bold]最新回测[/bold]")
2222
+ if bt_ver and bt_ver.backtest_result:
2223
+ br = bt_ver.backtest_result
2224
+ self.context.console.print(
2225
+ f" [dim]({bt_ver.version_tag})[/dim] 总收益 {_pct(_num(br,'total_return_pct','total_return'))} · "
2226
+ f"年化 {_pct(_num(br,'annualized_return','annual_return'))} · "
2227
+ f"Sharpe {_num(br,'sharpe_ratio') if _num(br,'sharpe_ratio') is not None else '—'} · "
2228
+ f"回撤 {_pct(_num(br,'max_drawdown'))} · 胜率 {_pct(_num(br,'win_rate'))}")
2229
+ spark = _spark(br.get("equity_curve"))
2230
+ if spark:
2231
+ self.context.console.print(f" [green]{spark}[/green]")
2232
+ else:
2233
+ self.context.console.print(" [dim]尚无回测。运行 /backtest 后回测结果会关联到该策略版本。[/dim]")
2234
+
2235
+ self.context.console.print("\n [bold]实盘部署[/bold]")
2236
+ try:
2237
+ from portfolio_ledger import PortfolioLedger
2238
+ trades = PortfolioLedger().get_trades(limit=2000)
2239
+ tagged = [t for t in trades if name.lower() in str(t.get("reason") or "").lower()]
2240
+ if not tagged:
2241
+ self.context.console.print(f" [dim]未部署到实盘。给交易加 reason 含 '{name}' 即可在此关联。[/dim]")
2242
+ else:
2243
+ # 从标记交易聚合每个标的:净持仓 + 买入均价
2244
+ agg = {} # sym -> [net_qty, buy_qty, buy_cost]
2245
+ for t in tagged:
2246
+ q = float(t.get("qty") or 0)
2247
+ px = float(t.get("price") or 0)
2248
+ a = agg.setdefault(t["symbol"], [0.0, 0.0, 0.0])
2249
+ if str(t.get("side")).upper() == "BUY":
2250
+ a[0] += q; a[1] += q; a[2] += q * px
2251
+ else:
2252
+ a[0] -= q
2253
+ held = {s: v for s, v in agg.items() if abs(v[0]) > 1e-6}
2254
+ if not held:
2255
+ self.context.console.print(f" [green]已部署[/green] · {len(tagged)} 笔标记交易 · [dim](已全部平仓)[/dim]")
2256
+ else:
2257
+ self.context.console.print(f" [dim]获取 {len(held)} 只实时报价…[/dim]")
2258
+ live = {}
2259
+ try:
2260
+ import yfinance as yf
2261
+ for s in held:
2262
+ try:
2263
+ h = yf.Ticker(s).history(period="1d")
2264
+ if not h.empty:
2265
+ live[s] = float(h["Close"].iloc[-1])
2266
+ except Exception:
2267
+ pass
2268
+ except ImportError:
2269
+ pass
2270
+
2271
+ from rich.table import Table
2272
+ tbl = Table(box=None, header_style="bold", pad_edge=False)
2273
+ tbl.add_column("标的", width=8)
2274
+ tbl.add_column("净持仓", justify="right", width=10)
2275
+ tbl.add_column("买入均价", justify="right", width=10)
2276
+ tbl.add_column("现价", justify="right", width=10)
2277
+ tbl.add_column("浮动盈亏", justify="right", width=13)
2278
+ tbl.add_column("%", justify="right", width=8)
2279
+ tot_cost = tot_mv = 0.0
2280
+ have_px = False
2281
+ for s, (nq, bq, bc) in sorted(held.items()):
2282
+ avg = bc / bq if bq else 0.0
2283
+ cost = nq * avg
2284
+ tot_cost += cost
2285
+ px = live.get(s)
2286
+ if px is not None:
2287
+ have_px = True
2288
+ mv = nq * px
2289
+ tot_mv += mv
2290
+ pnl = mv - cost
2291
+ pct = (pnl / cost * 100) if cost else 0.0
2292
+ c = "green" if pnl >= 0 else "red"
2293
+ tbl.add_row(s, f"{nq:,.4g}", f"{avg:,.4f}", f"{px:,.4f}",
2294
+ f"[{c}]{pnl:+,.2f}[/{c}]", f"[{c}]{pct:+.1f}%[/{c}]")
2295
+ else:
2296
+ tot_mv += cost
2297
+ tbl.add_row(s, f"{nq:,.4g}", f"{avg:,.4f}", "N/A", "—", "—")
2298
+ self.context.console.print(tbl)
2299
+ if have_px and tot_cost:
2300
+ live_pnl = tot_mv - tot_cost
2301
+ live_pct = live_pnl / tot_cost * 100
2302
+ c = "green" if live_pnl >= 0 else "red"
2303
+ self.context.console.print(f" [bold]实盘 {len(tagged)} 笔 · 成本 {tot_cost:,.0f} · "
2304
+ f"浮盈 [{c}]{live_pnl:+,.0f} ({live_pct:+.1f}%)[/{c}][/bold]")
2305
+ # vs 回测:口径不同(实盘自部署以来 vs 回测全程),仅作参考信号
2306
+ if bt_ver and bt_ver.backtest_result:
2307
+ bt_ret = _num(bt_ver.backtest_result, "total_return_pct", "total_return")
2308
+ if bt_ret is not None:
2309
+ bt_pct = bt_ret * 100 if abs(bt_ret) < 5 else bt_ret
2310
+ gap = live_pct - bt_pct
2311
+ gc = "green" if gap >= 0 else "yellow"
2312
+ self.context.console.print(f" [dim]vs 回测总收益 {bt_pct:+.1f}% · "
2313
+ f"偏离 [{gc}]{gap:+.1f}pp[/{gc}] (口径不同,仅供参考)[/dim]")
2314
+ else:
2315
+ pos_txt = ", ".join(f"{s} {v[0]:g}" for s, v in sorted(held.items()))
2316
+ self.context.console.print(f" [green]已部署[/green] · {len(tagged)} 笔 · {pos_txt} [dim](报价不可用)[/dim]")
2317
+ except Exception:
2318
+ self.context.console.print(" [dim]持仓账本不可用。[/dim]")
2319
+ self.context.console.print()
2320
+ else:
2321
+ print(f"Strategy {name}: latest {latest.version_tag}, {len(versions)} versions")
2322
+ if bt_ver and bt_ver.backtest_result:
2323
+ br = bt_ver.backtest_result
2324
+ print(f" backtest: sharpe={br.get('sharpe_ratio')} "
2325
+ f"return={br.get('total_return_pct', br.get('total_return'))}")
2326
+ try:
2327
+ from portfolio_ledger import PortfolioLedger
2328
+ tagged = [t for t in PortfolioLedger().get_trades(limit=2000)
2329
+ if name.lower() in str(t.get("reason") or "").lower()]
2330
+ if tagged:
2331
+ print(f" deployed: {len(tagged)} tagged trades")
2332
+ except Exception:
2333
+ pass
2334
+
2335
+ # ── diff ──────────────────────────────────────────────────────────
2336
+ elif sub == "diff":
2337
+ name = parts[1] if len(parts) > 1 else "strategy"
2338
+ tag_a = parts[2] if len(parts) > 2 else None
2339
+ tag_b = parts[3] if len(parts) > 3 else None
2340
+ diff_text = vault.diff(name, tag_a, tag_b)
2341
+ if self.context.has_rich:
2342
+ self.context.console.print()
2343
+ # Simple color: + lines green, - lines red
2344
+ for line in diff_text.splitlines():
2345
+ if line.startswith("+++") or line.startswith("---"):
2346
+ self.context.console.print(f" [bold]{line}[/bold]")
2347
+ elif line.startswith("+"):
2348
+ self.context.console.print(f" [green]{line}[/green]")
2349
+ elif line.startswith("-"):
2350
+ self.context.console.print(f" [red]{line}[/red]")
2351
+ elif line.startswith("@@"):
2352
+ self.context.console.print(f" [cyan]{line}[/cyan]")
2353
+ else:
2354
+ self.context.console.print(f" {line}")
2355
+ self.context.console.print()
2356
+ else:
2357
+ print(diff_text)
2358
+
2359
+ # ── load ──────────────────────────────────────────────────────────
2360
+ elif sub == "load":
2361
+ name = parts[1] if len(parts) > 1 else "strategy"
2362
+ tag = parts[2] if len(parts) > 2 else None
2363
+ version = vault.load(name, version_tag=tag)
2364
+ if not version:
2365
+ self.context.console.print(f" [red]未找到: {name} {tag or '(latest)'}[/red]" if self.context.has_rich
2366
+ else f" Not found: {name} {tag}")
2367
+ return
2368
+ # Inject code into conversation context as a user message
2369
+ code_msg = f"以下是策略 {version.name} {version.version_tag} 的代码:\n\n```python\n{version.code}\n```"
2370
+ self.terminal.conversation.append({"role": "assistant", "content": code_msg})
2371
+ if self.context.has_rich:
2372
+ self.context.console.print(
2373
+ f"\n [green]✓[/green] 已加载 [bold]{version.name} {version.version_tag}[/bold] "
2374
+ f"[dim]{len(version.code)} chars {version.created_at[:16]}[/dim]"
2375
+ )
2376
+ self.context.console.print(f" [dim]{version.message}[/dim]" if version.message else "")
2377
+ lines = version.code.count("\n")
2378
+ self.context.console.print(f" [dim]代码 {lines} 行已注入上下文,可继续对话修改。[/dim]")
2379
+ else:
2380
+ print(f" Loaded: {version.name} {version.version_tag}")
2381
+
2382
+ # ── review ────────────────────────────────────────────────────────
2383
+ elif sub == "review":
2384
+ name = parts[1] if len(parts) > 1 else "strategy"
2385
+ tag = parts[2] if len(parts) > 2 else None
2386
+ version = vault.load(name, version_tag=tag)
2387
+ if not version:
2388
+ code = self._extract_last_code()
2389
+ if not code:
2390
+ self.context.console.print(" [yellow]未找到策略,请先 /strategy save 或生成代码[/yellow]" if self.context.has_rich
2391
+ else " No strategy found.")
2392
+ return
2393
+ ver_id = None
2394
+ else:
2395
+ code = version.code
2396
+ ver_id = version.id
2397
+
2398
+ if self.context.has_rich:
2399
+ self.context.console.print()
2400
+ self.context.console.print(" [bold]🔬 策略审查中...[/bold]")
2401
+ self.context.console.print()
2402
+
2403
+ ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
2404
+ model = self.terminal.config.get("model", "qwen2.5:7b")
2405
+ bt_result = version.backtest_result if version else None
2406
+
2407
+ import sys
2408
+ def on_token(tok):
2409
+ sys.stdout.write(tok)
2410
+ sys.stdout.flush()
2411
+
2412
+ review = await _ai_review(code, bt_result, ollama_url, model, on_token=on_token)
2413
+
2414
+ # Print static results
2415
+ static = review.get("static", {})
2416
+ if self.context.has_rich:
2417
+ self.context.console.print()
2418
+ self.context.console.print(f"\n [bold]静态检测[/bold] 评级:{static.get('grade','?')} "
2419
+ f"{static.get('summary','')}")
2420
+ for e in static.get("errors", []):
2421
+ self.context.console.print(f" [red]❌ {e['detail']}[/red]")
2422
+ for w in static.get("warnings", []):
2423
+ self.context.console.print(f" [yellow]⚠️ {w['detail']}[/yellow]")
2424
+ for q in static.get("quality_checks", []):
2425
+ self.context.console.print(f" [dim]💡 {q}[/dim]")
2426
+ self.context.console.print()
2427
+ else:
2428
+ print(f"\n Static: {static.get('summary','')}")
2429
+
2430
+ if ver_id:
2431
+ vault.save_review(ver_id, review)
2432
+ if self.context.has_rich:
2433
+ self.context.console.print(" [dim]审查结果已保存到策略金库[/dim]")
2434
+
2435
+ else:
2436
+ if self.context.has_rich:
2437
+ self.context.console.print(
2438
+ "\n [bold]Strategy Vault 命令[/bold]\n\n"
2439
+ " /strategy save [name] [message] 保存当前代码快照\n"
2440
+ " /strategy list [name] 列出版本历史\n"
2441
+ " /strategy diff [name] [v1] [v2] 查看版本差异\n"
2442
+ " /strategy load [name] [tag] 加载版本到上下文\n"
2443
+ " /strategy review [name] [tag] AI + 静态代码审查\n"
2444
+ )
2445
+ else:
2446
+ print(" Usage: /strategy save|list|diff|load|review [name] [tag]")
2447
+
2448
+ # ── ML 信号组合回测 ──────────────────────────────────────────────────────────
2449
+
2450
+ async def _cmd_ml_signal_backtest(
2451
+ self, symbol_args: list, start_date: str = "2023-01-01",
2452
+ end_date: str = "", capital: float = 1_000_000,
2453
+ ):
2454
+ """
2455
+ /backtest ml [sym1 sym2 ...] [--start YYYY-MM-DD] [--capital N]
2456
+
2457
+ 三策略对比: ML-Weighted / Equal-Weight / Buy-and-Hold
2458
+ 支持 A股(T+1)、港股、美股混合组合。
2459
+ """
2460
+ # ML signal backtest is part of the private Arthera engine (alpha IP).
2461
+ # If a local Arthera checkout is present (dev), make it importable;
2462
+ # otherwise the import below fails and we show a Pro-feature notice.
2463
+
2464
+ import sys
2465
+ import os
2466
+ _arthera_pkgs = os.environ.get("ARTHERA_ROOT") or os.path.expanduser("~/Desktop/Arthera")
2467
+ _arthera_pkgs = os.path.join(_arthera_pkgs, "packages")
2468
+ if os.path.isdir(_arthera_pkgs) and _arthera_pkgs not in sys.path:
2469
+ sys.path.insert(0, _arthera_pkgs)
2470
+
2471
+ if self.context.has_rich:
2472
+ self.context.console.print("\n [bold cyan]ML 信号组合回测[/bold cyan] 三策略对比\n")
2473
+ else:
2474
+ print("\n ML 信号组合回测 三策略对比\n")
2475
+
2476
+ # 解析标的列表(去掉标志位)
2477
+ symbols = [s.upper() for s in symbol_args if not s.startswith("--")]
2478
+ if not symbols:
2479
+ symbols = ["600519", "300750", "NVDA", "AAPL"]
2480
+ if self.context.has_rich:
2481
+ self.context.console.print(f" [dim]未指定标的,使用默认组合: {symbols}[/dim]")
2482
+
2483
+ if self.context.has_rich:
2484
+ self.context.console.print(f" 标的: [yellow]{' | '.join(symbols)}[/yellow]")
2485
+ self.context.console.print(f" 区间: {start_date} → {end_date or '今日'}")
2486
+ self.context.console.print(f" 初始资金: {capital:,.0f}\n")
2487
+ self.context.console.print(" [dim]正在拉取行情并训练模型,请稍候…[/dim]")
2488
+
2489
+ try:
2490
+ from quant_engine.backtest.ml_signal_backtest import MLSignalBacktest
2491
+
2492
+ bt = MLSignalBacktest(
2493
+ symbols=symbols,
2494
+ initial_cash=capital,
2495
+ rebalance_freq="W",
2496
+ )
2497
+ report = bt.run(start=start_date, end=end_date or "")
2498
+ report.print_report()
2499
+
2500
+ if self.context.has_rich:
2501
+ # 额外渲染净值图(纯 ASCII sparkline)
2502
+ ml_nav = report.ml_strategy.nav_series
2503
+ ew_nav = report.ew_strategy.nav_series
2504
+ if not ml_nav.empty and not ew_nav.empty:
2505
+ self.context.console.print("\n [bold]净值走势(最近 40 个交易日)[/bold]")
2506
+ _print_sparkline("ML 权重", ml_nav, "cyan")
2507
+ _print_sparkline("等权基准", ew_nav, "yellow")
2508
+
2509
+ except ImportError:
2510
+ # Moat feature — the ML/alpha engine ships only with the full
2511
+ # Arthera platform, not the open CLI. Degrade with a clear notice.
2512
+ _msg = ("ML 信号回测属于 Arthera 高级引擎(含 ML 选股/alpha 因子),"
2513
+ "开源 CLI 未内置。\n 基础回测可用:/backtest momentum <symbol>")
2514
+ if self.context.has_rich:
2515
+ self.context.console.print(f" [#C08050]◆ Pro 功能[/#C08050] [dim]{_msg}[/dim]")
2516
+ else:
2517
+ print(f" ◆ Pro 功能 {_msg}")
2518
+ except Exception as e:
2519
+ print_error(self.context, f"ML 回测失败: {e}")
2520
+ import traceback
2521
+ self.context.console.print(f" [dim]{traceback.format_exc()}[/dim]") if self.context.has_rich else print(traceback.format_exc())
2522
+
2523
+
2524
+ def _print_sparkline(label: str, nav: "pd.Series", color: str = "white", width: int = 40):
2525
+ """打印 ASCII sparkline。"""
2526
+ try:
2527
+ import sys
2528
+ HAS_RICH = "rich" in sys.modules
2529
+ vals = nav.iloc[-width:].values if len(nav) > width else nav.values
2530
+ if len(vals) < 2:
2531
+ return
2532
+ lo, hi = vals.min(), vals.max()
2533
+ chars = "▁▂▃▄▅▆▇█"
2534
+ spark = "".join(chars[min(7, int((v - lo) / (hi - lo + 1e-9) * 8))] for v in vals)
2535
+ change = (vals[-1] / vals[0] - 1) * 100
2536
+ sign = "+" if change >= 0 else ""
2537
+ if has_rich():
2538
+ from rich.console import Console as _C
2539
+ _C().print(f" [{color}]{label:<8}[/{color}] {spark} [{color}]{sign}{change:.2f}%[/{color}]")
2540
+ else:
2541
+ print(f" {label:<8} {spark} {sign}{change:.2f}%")
2542
+ except Exception:
2543
+ pass