aria-code 0.167.0__py3-none-win_amd64.whl

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