aria-code 4.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
@@ -0,0 +1,1887 @@
1
+ """BacktestCommandsMixin — backtest/strategy/scaffold commands.
2
+
3
+ Extracted from aria_cli.py. Module globals imported lazily inside method bodies.
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ def format_backtest_data_error(
9
+ symbol: str,
10
+ *,
11
+ start_date: str,
12
+ end_date: str,
13
+ local_error: str = "",
14
+ bars: int = 0,
15
+ ) -> str:
16
+ """Return a user-facing backtest failure message."""
17
+ if bars and bars < 5:
18
+ return (
19
+ f"{symbol} 历史数据仅 {bars} 个交易日,不足以回测。"
20
+ "请换历史更长的标的或缩短策略周期。"
21
+ )
22
+ if local_error:
23
+ low = local_error.lower()
24
+ if "histor" in low or "data" in low or "empty" in low:
25
+ return (
26
+ f"{symbol} 回测失败:{local_error}。"
27
+ "请检查数据源是否可用、ticker 是否正确,或先运行 /doctor /health。"
28
+ )
29
+ return (
30
+ f"{symbol} 在 {start_date} → {end_date} 范围内没有可用历史数据。"
31
+ "请检查代码是否正确、标的是否已上市/未停牌,或缩短回测区间。"
32
+ )
33
+
34
+
35
+ def _bt_num(value, default: float = 0.0) -> float:
36
+ try:
37
+ return float(value)
38
+ except Exception:
39
+ return default
40
+
41
+
42
+ def _bt_pct(value, digits: int = 1, signed: bool = False) -> str:
43
+ number = _bt_num(value)
44
+ sign = "+" if signed and number >= 0 else ""
45
+ return f"{sign}{number * 100:.{digits}f}%"
46
+
47
+
48
+ def _bt_money(value, currency: str = "USD") -> str:
49
+ number = _bt_num(value)
50
+ return f"{currency} {number:,.0f}" if abs(number) >= 1000 else f"{currency} {number:,.2f}"
51
+
52
+
53
+ def _bt_int(value, default: int = 0) -> int:
54
+ try:
55
+ return int(value)
56
+ except Exception:
57
+ return default
58
+
59
+
60
+ def _bt_trade_count(data: dict) -> int:
61
+ for key in ("total_trades", "num_trades", "n_trades", "trades"):
62
+ if key in data and data.get(key) is not None:
63
+ return _bt_int(data.get(key))
64
+ return 0
65
+
66
+
67
+ def _bt_value(data: dict, *keys, default=None):
68
+ for key in keys:
69
+ if key in data and data.get(key) is not None:
70
+ return data.get(key)
71
+ return default
72
+
73
+
74
+ def _bt_volume_summary(data: dict) -> dict:
75
+ summary = data.get("volume_summary")
76
+ return summary if isinstance(summary, dict) else {}
77
+
78
+
79
+ def _bt_result_summary(data: dict) -> str:
80
+ total = _bt_num(data.get("total_return"))
81
+ benchmark = _bt_num(_bt_value(data, "buy_hold_return", "benchmark_return", default=0))
82
+ sharpe = _bt_num(data.get("sharpe_ratio"))
83
+ max_dd = _bt_num(data.get("max_drawdown"))
84
+ relation = "高于" if total > benchmark else "低于" if total < benchmark else "持平"
85
+ return (
86
+ f"结论:策略收益 {_bt_pct(total)},{relation}买入持有 {_bt_pct(benchmark)};"
87
+ f"Sharpe {sharpe:.2f},最大回撤 {_bt_pct(max_dd)}。"
88
+ )
89
+
90
+
91
+ class BacktestCommandsMixin:
92
+ """Mixin providing backtest, strategy, factor-lab, and scaffold commands."""
93
+
94
+ _bt_num = staticmethod(_bt_num)
95
+ _bt_pct = staticmethod(_bt_pct)
96
+ _bt_money = staticmethod(_bt_money)
97
+ _bt_int = staticmethod(_bt_int)
98
+ _bt_trade_count = staticmethod(_bt_trade_count)
99
+ _bt_value = staticmethod(_bt_value)
100
+ _bt_volume_summary = staticmethod(_bt_volume_summary)
101
+ _bt_result_summary = staticmethod(_bt_result_summary)
102
+
103
+ async def cmd_backtest(self, args: str):
104
+ """Direct REST backtest → /api/v1/backtest (falls back to Aria tool).
105
+
106
+ Usage:
107
+ /backtest [strategy] [symbol] [start_date] [end_date]
108
+ /backtest momentum AAPL 2023-01-01 2024-12-31
109
+ /backtest momentum AAPL --period 1y
110
+ /backtest momentum AAPL --period 6m
111
+ """
112
+ import re as _re_bt
113
+ today = __import__("datetime").date.today()
114
+
115
+ raw_parts = args.split() if args else ["momentum", "SPY"]
116
+
117
+ # Handle flags (e.g. --period 1y, --fast 20, --slow 60, --symbol AAPL)
118
+ _period_match = None
119
+ _symbol_flag = None
120
+ _fast_period = 20
121
+ _slow_period = 60
122
+ _momentum_period = 20
123
+ _initial_capital = 100000
124
+ _output_dir = None
125
+ _cleaned = []
126
+ i = 0
127
+ while i < len(raw_parts):
128
+ if raw_parts[i] == "--period" and i + 1 < len(raw_parts):
129
+ _period_match = raw_parts[i + 1]
130
+ i += 2
131
+ elif raw_parts[i].startswith("--period="):
132
+ _period_match = raw_parts[i].split("=", 1)[1]
133
+ i += 1
134
+ elif raw_parts[i] == "--symbol" and i + 1 < len(raw_parts):
135
+ _symbol_flag = raw_parts[i + 1].upper()
136
+ i += 2
137
+ elif raw_parts[i].startswith("--symbol="):
138
+ _symbol_flag = raw_parts[i].split("=", 1)[1].upper()
139
+ i += 1
140
+ elif raw_parts[i] == "--fast" and i + 1 < len(raw_parts):
141
+ try:
142
+ _fast_period = int(raw_parts[i + 1])
143
+ except Exception:
144
+ pass
145
+ i += 2
146
+ elif raw_parts[i].startswith("--fast="):
147
+ try:
148
+ _fast_period = int(raw_parts[i].split("=", 1)[1])
149
+ except Exception:
150
+ pass
151
+ i += 1
152
+ elif raw_parts[i] == "--slow" and i + 1 < len(raw_parts):
153
+ try:
154
+ _slow_period = int(raw_parts[i + 1])
155
+ except Exception:
156
+ pass
157
+ i += 2
158
+ elif raw_parts[i].startswith("--slow="):
159
+ try:
160
+ _slow_period = int(raw_parts[i].split("=", 1)[1])
161
+ except Exception:
162
+ pass
163
+ i += 1
164
+ elif raw_parts[i] == "--momentum" and i + 1 < len(raw_parts):
165
+ try:
166
+ _momentum_period = int(raw_parts[i + 1])
167
+ except Exception:
168
+ pass
169
+ i += 2
170
+ elif raw_parts[i].startswith("--momentum="):
171
+ try:
172
+ _momentum_period = int(raw_parts[i].split("=", 1)[1])
173
+ except Exception:
174
+ pass
175
+ i += 1
176
+ elif raw_parts[i] == "--capital" and i + 1 < len(raw_parts):
177
+ try:
178
+ _initial_capital = float(raw_parts[i + 1])
179
+ except Exception:
180
+ pass
181
+ i += 2
182
+ elif raw_parts[i].startswith("--capital="):
183
+ try:
184
+ _initial_capital = float(raw_parts[i].split("=", 1)[1])
185
+ except Exception:
186
+ pass
187
+ i += 1
188
+ elif raw_parts[i] == "--output" and i + 1 < len(raw_parts):
189
+ _output_dir = raw_parts[i + 1]
190
+ i += 2
191
+ elif raw_parts[i].startswith("--output="):
192
+ _output_dir = raw_parts[i].split("=", 1)[1]
193
+ i += 1
194
+ else:
195
+ _cleaned.append(raw_parts[i])
196
+ i += 1
197
+ parts = _cleaned
198
+
199
+ # Resolve --period to a start date
200
+ if _period_match:
201
+ _pm = _period_match.lower()
202
+ _months = {"1m": 1, "3m": 3, "6m": 6, "1y": 12, "2y": 24, "3y": 36, "5y": 60}
203
+ if _pm in _months:
204
+ from datetime import timedelta
205
+ _delta_days = _months[_pm] * 30
206
+ _start_dt = today - timedelta(days=_delta_days)
207
+ _resolved_start = _start_dt.isoformat()
208
+ else:
209
+ _resolved_start = None
210
+ else:
211
+ _resolved_start = None
212
+
213
+ _known_strategies = {"momentum", "mom", "sma_cross", "ma_cross", "moving_average",
214
+ "buy_hold", "buyhold", "hold", "ml", "ml_signal"}
215
+ if len(parts) == 1 and parts[0].lower() not in _known_strategies:
216
+ strategy = "momentum"
217
+ symbol = parts[0].upper()
218
+ else:
219
+ strategy = parts[0] if len(parts) > 0 else "momentum"
220
+ symbol = parts[1].upper() if len(parts) > 1 else "SPY"
221
+ if _symbol_flag:
222
+ symbol = _symbol_flag
223
+
224
+ # ── ML 信号组合回测 ──────────────────────────────────────────────────
225
+ if strategy.lower() in ("ml", "ml_signal"):
226
+ await self._cmd_ml_signal_backtest(parts[1:], start_date=start_date,
227
+ end_date=end_date,
228
+ capital=_initial_capital)
229
+ return
230
+
231
+ # Positional start/end dates only accepted if they look like YYYY-MM-DD
232
+ _date_re = _re_bt.compile(r'^\d{4}-\d{2}-\d{2}$')
233
+ _raw_start = parts[2] if len(parts) > 2 else None
234
+ _raw_end = parts[3] if len(parts) > 3 else None
235
+ start_date = (_raw_start if _raw_start and _date_re.match(_raw_start) else None) \
236
+ or _resolved_start or "2023-01-01"
237
+ end_date = (_raw_end if _raw_end and _date_re.match(_raw_end) else None) \
238
+ or today.isoformat()
239
+
240
+ label = f"Backtesting {strategy} on {symbol} ({start_date}→{end_date})"
241
+ api_url = self.terminal.config.get("api_url", "http://localhost:8000")
242
+
243
+ async def _do_backtest():
244
+ from backtest_report import BacktestConfig, generate_backtest_report
245
+ local_config = BacktestConfig(
246
+ symbol=symbol,
247
+ strategy=strategy,
248
+ start_date=start_date,
249
+ end_date=end_date,
250
+ initial_capital=float(_initial_capital),
251
+ fast_period=int(_fast_period),
252
+ slow_period=int(_slow_period),
253
+ momentum_period=int(_momentum_period),
254
+ )
255
+ local_error = ""
256
+ try:
257
+ _out_path = None
258
+ if _output_dir:
259
+ _out_path = pathlib.Path(_output_dir).expanduser()
260
+ if not _out_path.is_absolute():
261
+ from artifacts import user_generated_dir
262
+ _out_path = user_generated_dir() / _out_path
263
+ local_result = await asyncio.get_event_loop().run_in_executor(
264
+ None, lambda: generate_backtest_report(local_config, output_dir=_out_path)
265
+ )
266
+ if local_result and local_result.get("success"):
267
+ return {"success": True, "data": local_result, "_source": "local-real-data"}
268
+ local_error = local_result.get("error") if local_result else ""
269
+ logger.debug("local backtest failed, falling back to yfinance direct: %s", local_error or "None")
270
+ except Exception as _e:
271
+ local_error = str(_e)
272
+ logger.debug("local backtest failed, falling back to yfinance direct: %s", _e)
273
+
274
+ # ── Direct yfinance backtest — works offline, no backend needed ──
275
+ try:
276
+ import yfinance as _yf
277
+ import numpy as _np
278
+ import statistics as _stats
279
+
280
+ _yf_bars = [0] # records bars found, for a precise error message
281
+
282
+ def _run_yf_backtest():
283
+ _ticker = _yf.Ticker(symbol)
284
+ _df = _ticker.history(start=start_date, end=end_date, auto_adjust=True)
285
+ if _df is None or _df.empty:
286
+ return {"success": False, "error": format_backtest_data_error(
287
+ symbol,
288
+ start_date=start_date,
289
+ end_date=end_date,
290
+ bars=0,
291
+ ), "bars": 0}
292
+ _close = _df["Close"].dropna()
293
+ _yf_bars[0] = len(_close)
294
+ if len(_close) < 5:
295
+ return {"success": False, "error": format_backtest_data_error(
296
+ symbol,
297
+ start_date=start_date,
298
+ end_date=end_date,
299
+ bars=_yf_bars[0],
300
+ ), "bars": _yf_bars[0]}
301
+ _prices = list(_close)
302
+ n = len(_prices)
303
+ # Momentum strategy: buy when N-day momentum > 0
304
+ _mp = int(_momentum_period)
305
+ _signals = [0] * n
306
+ for i in range(_mp, n):
307
+ _signals[i] = 1 if _prices[i] > _prices[i - _mp] else -1
308
+ # Simulate portfolio
309
+ _cap = float(_initial_capital)
310
+ _position = 0.0 # shares
311
+ _cash = _cap
312
+ _trades = 0
313
+ _portfolio = []
314
+ for i in range(1, n):
315
+ _p = _prices[i]
316
+ _sig = _signals[i - 1]
317
+ if _sig == 1 and _position == 0 and _cash > 0:
318
+ _shares = _cash / _p
319
+ _position = _shares
320
+ _cash = 0
321
+ _trades += 1
322
+ elif _sig == -1 and _position > 0:
323
+ _cash = _position * _p
324
+ _position = 0
325
+ _trades += 1
326
+ _portfolio.append(_cash + _position * _p)
327
+ if not _portfolio:
328
+ return None
329
+ _final = _portfolio[-1]
330
+ _total_return = (_final - _cap) / _cap
331
+ _bh_return = (_prices[-1] - _prices[0]) / _prices[0]
332
+ # Daily returns for Sharpe
333
+ _rets = [(_portfolio[i] - _portfolio[i-1]) / _portfolio[i-1] for i in range(1, len(_portfolio)) if _portfolio[i-1] > 0]
334
+ _ann_return = sum(_rets) / len(_rets) * 252 if _rets else 0
335
+ _ann_vol = _stats.stdev(_rets) * (252 ** 0.5) if len(_rets) > 1 else 0
336
+ _sharpe = _ann_return / _ann_vol if _ann_vol > 0 else 0
337
+ # Max drawdown
338
+ _peak = _portfolio[0]
339
+ _max_dd = 0.0
340
+ for v in _portfolio:
341
+ if v > _peak:
342
+ _peak = v
343
+ _dd = (_peak - v) / _peak if _peak > 0 else 0
344
+ if _dd > _max_dd:
345
+ _max_dd = _dd
346
+ # Equity curve (sampled monthly)
347
+ _step = max(1, n // 24)
348
+ _equity_curve = [
349
+ {"date": str(_close.index[min(i + 1, n - 1)].date()), "strategy": round(_portfolio[min(i, len(_portfolio)-1)], 2)}
350
+ for i in range(0, len(_portfolio), _step)
351
+ ]
352
+ _win_trades = sum(1 for i in range(1, len(_portfolio)) if _portfolio[i] > _portfolio[i-1])
353
+ _vol = _df["Volume"].dropna() if "Volume" in _df else []
354
+ _vol_count = len(_vol) if hasattr(_vol, "__len__") else 0
355
+ return {
356
+ "success": True,
357
+ "symbol": symbol,
358
+ "strategy": strategy,
359
+ "total_return": round(_total_return, 4),
360
+ "buy_hold_return": round(_bh_return, 4),
361
+ "annualized_return": round(_ann_return, 4),
362
+ "sharpe_ratio": round(_sharpe, 3),
363
+ "max_drawdown": round(-_max_dd, 4),
364
+ "win_rate": round(_win_trades / max(len(_portfolio) - 1, 1), 3),
365
+ "num_trades": _trades,
366
+ "equity_curve": _equity_curve,
367
+ "data_provider": "yfinance",
368
+ "provider_chain": ["yfinance"],
369
+ "start_date": start_date,
370
+ "end_date": end_date,
371
+ "initial_capital": float(_initial_capital),
372
+ "bars": n,
373
+ "volume_summary": {
374
+ "last": round(float(_vol.iloc[-1]), 2) if _vol_count else None,
375
+ "average": round(float(_vol.mean()), 2) if _vol_count else None,
376
+ "min": round(float(_vol.min()), 2) if _vol_count else None,
377
+ "max": round(float(_vol.max()), 2) if _vol_count else None,
378
+ "coverage": round(_vol_count / max(len(_df), 1), 4),
379
+ },
380
+ }
381
+
382
+ yf_result = await asyncio.get_event_loop().run_in_executor(None, _run_yf_backtest)
383
+ if yf_result and yf_result.get("success"):
384
+ return {"success": True, "data": yf_result, "_source": "yfinance-local"}
385
+ if yf_result and not yf_result.get("success"):
386
+ return yf_result
387
+ except Exception as _e:
388
+ logger.debug("yfinance direct backtest failed: %s", _e)
389
+
390
+ import aiohttp
391
+ payload = {
392
+ "symbols": [symbol],
393
+ "strategy_type": strategy,
394
+ "start_date": start_date,
395
+ "end_date": end_date,
396
+ "initial_capital": float(_initial_capital),
397
+ "commission_rate": 0.0003,
398
+ "include_monte_carlo": False,
399
+ }
400
+ try:
401
+ async with aiohttp.ClientSession() as sess:
402
+ async with sess.post(f"{api_url}/api/v1/backtest", json=payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
403
+ if resp.status == 200:
404
+ body = await resp.json()
405
+ _rest_data = body.get("data", body)
406
+ if _rest_data and isinstance(_rest_data, dict):
407
+ return {"success": True, "data": _rest_data, "_source": "rest"}
408
+ except Exception as _e:
409
+ logger.debug("backtest REST call failed: %s", _e)
410
+ # Honest, actionable error: the dominant cause is too little history
411
+ # (new IPO / halted / wrong ticker), not "all data sources down".
412
+ if 0 < _yf_bars[0] < 5:
413
+ return {"success": False, "error": format_backtest_data_error(
414
+ symbol,
415
+ start_date=start_date,
416
+ end_date=end_date,
417
+ bars=_yf_bars[0],
418
+ )}
419
+ return {"success": False, "error": format_backtest_data_error(
420
+ symbol,
421
+ start_date=start_date,
422
+ end_date=end_date,
423
+ local_error=local_error,
424
+ bars=_yf_bars[0],
425
+ )}
426
+
427
+ if HAS_RICH:
428
+ with console.status(f"[dim]{label}...[/dim]", spinner="dots"):
429
+ result = await _do_backtest()
430
+ else:
431
+ print(label)
432
+ result = await _do_backtest()
433
+
434
+ # Guard: execute_aria_tool / REST fallback can return None
435
+ if not result:
436
+ _print_error("回测服务不可用 (API未运行) — 先启动后端: cd apps/api && python -m uvicorn src.main:app", "tool")
437
+ return
438
+
439
+ if result.get("success"):
440
+ d = result.get("data", result)
441
+ if not isinstance(d, dict):
442
+ _print_error(f"回测结果格式异常: {type(d)}", "tool")
443
+ return
444
+ src = result.get("_source", "aria")
445
+ if HAS_RICH:
446
+ from rich.table import Table
447
+ tbl = Table(title=f"[bold]{symbol} · {strategy.upper()}[/bold]", show_header=True, header_style="bold")
448
+ tbl.add_column("Metric", style="#57606a")
449
+ tbl.add_column("Value", justify="right")
450
+ tbl.add_column("vs B&H", justify="right", style="#57606a")
451
+ bh = self._bt_num(self._bt_value(d, "buy_hold_return", "benchmark_return", default=0))
452
+ trades = self._bt_trade_count(d)
453
+ rows = [
454
+ ("Total Return", self._bt_pct(d.get("total_return")), self._bt_pct(bh)),
455
+ ("Ann. Return", self._bt_pct(self._bt_value(d, "annualized_return", "annual_return", default=0)), ""),
456
+ ("Sharpe Ratio", f"{self._bt_num(d.get('sharpe_ratio')):.2f}", ""),
457
+ ("Max Drawdown", self._bt_pct(d.get("max_drawdown")), ""),
458
+ ("Win Rate", self._bt_pct(d.get("win_rate")), ""),
459
+ ("# Trades", str(trades), ""),
460
+ ]
461
+ if d.get("calmar_ratio"):
462
+ rows.append(("Calmar Ratio", f"{d['calmar_ratio']:.2f}", ""))
463
+ if d.get("sortino_ratio"):
464
+ rows.append(("Sortino Ratio", f"{d['sortino_ratio']:.2f}", ""))
465
+ for r in rows:
466
+ tbl.add_row(*r)
467
+ console.print(tbl)
468
+ console.print(f" [bold]{self._bt_result_summary(d)}[/bold]")
469
+
470
+ actual_start = self._bt_value(d, "start", "start_date", default=start_date)
471
+ actual_end = self._bt_value(d, "end", "end_date", default=end_date)
472
+ bars = self._bt_int(d.get("bars"))
473
+ initial = self._bt_money(d.get("initial_capital", _initial_capital))
474
+ console.print(
475
+ f" [#57606a]source:[/#57606a] {src}"
476
+ f" [#57606a]period:[/#57606a] {actual_start} → {actual_end}"
477
+ f" [#57606a]bars:[/#57606a] {bars}"
478
+ f" [#57606a]capital:[/#57606a] {initial}"
479
+ )
480
+ console.print(
481
+ f" [#57606a]params:[/#57606a] "
482
+ f"momentum={_momentum_period} fast={_fast_period} slow={_slow_period}"
483
+ )
484
+ if d.get("provider_chain"):
485
+ chain = " → ".join(str(x) for x in d.get("provider_chain") or [])
486
+ status = d.get("data_status") or "complete"
487
+ missing = ", ".join(str(x) for x in (d.get("missing_fields") or [])) or "none"
488
+ console.print(
489
+ f" [#57606a]data:[/#57606a] {chain}"
490
+ f" [#57606a]status:[/#57606a] {status}"
491
+ f" [#57606a]missing:[/#57606a] {missing}"
492
+ )
493
+ vol = self._bt_volume_summary(d)
494
+ if vol:
495
+ avg = vol.get("average")
496
+ last = vol.get("last")
497
+ coverage = self._bt_num(vol.get("coverage"))
498
+ console.print(
499
+ f" [#57606a]volume:[/#57606a] "
500
+ f"avg {avg:,.0f} · last {last:,.0f} · coverage {coverage:.0%}"
501
+ if avg is not None and last is not None
502
+ else f" [#57606a]volume:[/#57606a] unavailable"
503
+ )
504
+ if d.get("report_path"):
505
+ console.print(f" [#57606a]report:[/#57606a] {d['report_path']}")
506
+ if trades == 0:
507
+ console.print(
508
+ " [yellow]注意:[/yellow] # Trades 为 0,表示本次规则没有触发入场;"
509
+ "收益可能来自全程空仓/持仓逻辑或上游交易统计口径。"
510
+ )
511
+ else:
512
+ 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}%")
513
+ if d.get("report_path"):
514
+ print(f"HTML Report: {d['report_path']}")
515
+
516
+ eq = d.get("equity_curve", [])
517
+ if eq:
518
+ strat_vals = [p.get("strategy", p.get("portfolio_value", 0)) for p in eq if isinstance(p, dict)]
519
+ if strat_vals:
520
+ spark = format_sparkline(strat_vals)
521
+ if spark:
522
+ console.print(f" [#57606a]Equity:[/#57606a] [green]{spark}[/green]" if HAS_RICH else f" Equity: {spark}")
523
+ await self._print_backtest_broker_plan(d)
524
+ else:
525
+ _print_error(f"Backtest failed: {result.get('error', 'Unknown')}", "tool")
526
+
527
+ async def _print_backtest_broker_plan(self, backtest_result: dict):
528
+ """Print an account-aware order plan for a successful backtest, if a broker is connected."""
529
+ if not _HAS_BROKERS or not isinstance(backtest_result, dict):
530
+ return
531
+ try:
532
+ reg = _get_broker_registry()
533
+ broker = reg.active() if reg else None
534
+ if not broker:
535
+ return
536
+ from brokers import plans_from_strategy_results, snapshot_from_broker
537
+ import asyncio as _aio
538
+
539
+ def _build_plan():
540
+ snapshot = snapshot_from_broker(broker)
541
+ plans = plans_from_strategy_results(snapshot, [backtest_result])
542
+ return snapshot, plans[0] if plans else None
543
+
544
+ snapshot, plan = await _aio.get_event_loop().run_in_executor(None, _build_plan)
545
+ if not plan:
546
+ return
547
+ data = plan.to_dict()
548
+ order = data.get("estimated_order") or {}
549
+ risk = data.get("risk") or {}
550
+ if HAS_RICH:
551
+ from rich.table import Table
552
+ t = Table(title=f"Broker Plan — {snapshot.broker_label}", show_header=False, box=None)
553
+ t.add_column("Field", style="dim")
554
+ t.add_column("Value")
555
+ t.add_row("Current Weight", f"{data.get('current_weight', 0) * 100:.2f}%")
556
+ t.add_row("Target Weight", f"{data.get('target_weight', 0) * 100:.2f}%")
557
+ if order:
558
+ side = "买入" if order.get("side") == "buy" else "卖出"
559
+ t.add_row("Suggested Order", f"{side} {order.get('quantity', 0):,.0f} {data.get('symbol')} @ {order.get('price', 0):,.2f}")
560
+ t.add_row("Estimated Value", f"{snapshot.currency} {order.get('estimated_value', 0):,.2f}")
561
+ t.add_row("Cash After", f"{snapshot.currency} {data.get('cash_after', 0):,.2f}")
562
+ else:
563
+ t.add_row("Suggested Order", "No trade")
564
+ status = "passed" if risk.get("passed") else "blocked"
565
+ t.add_row("Risk Gate", status)
566
+ console.print(t)
567
+ for msg in risk.get("violations", []):
568
+ console.print(f" [red]- {msg}[/red]")
569
+ for msg in risk.get("warnings", []):
570
+ console.print(f" [yellow]- {msg}[/yellow]")
571
+ if order and risk.get("passed"):
572
+ console.print(" [dim]这是订单计划,不会自动下单。执行前仍需用户明确确认。[/dim]")
573
+ else:
574
+ print(f"Broker Plan: {snapshot.broker_label}")
575
+ if order:
576
+ print(f" {order.get('side')} {order.get('quantity')} {data.get('symbol')} @ {order.get('price')}")
577
+ print(f" Risk: {'passed' if risk.get('passed') else 'blocked'}")
578
+ except Exception as exc:
579
+ logger.debug("backtest broker plan skipped: %s", exc)
580
+
581
+ async def cmd_walk_forward(self, args: str):
582
+ """Walk-Forward 滚动回测 → /api/v1/backtest/walk-forward"""
583
+ parts = args.split() if args else ["SPY"]
584
+ symbol = parts[0].upper() if parts else "SPY"
585
+ strategy = parts[1] if len(parts) > 1 else "momentum"
586
+ method = parts[2] if len(parts) > 2 else "rolling"
587
+ api_url = self.terminal.config.get("api_url", "http://localhost:8000")
588
+
589
+ label = f"Walk-Forward ({method}) · {strategy} · {symbol}"
590
+ import aiohttp
591
+
592
+ async def _do_wf():
593
+ payload = {
594
+ "symbol": symbol, "strategy_type": strategy, "method": method,
595
+ "start_date": "2020-01-01",
596
+ "end_date": __import__("datetime").date.today().isoformat(),
597
+ "train_period_days": 252, "test_period_days": 63, "step_days": 21,
598
+ }
599
+ async with aiohttp.ClientSession() as sess:
600
+ async with sess.post(f"{api_url}/api/v1/backtest/walk-forward", json=payload, timeout=aiohttp.ClientTimeout(total=90)) as resp:
601
+ if resp.status != 200:
602
+ raise RuntimeError(f"HTTP {resp.status}")
603
+ body = await resp.json()
604
+ return body.get("data", body)
605
+
606
+ if HAS_RICH:
607
+ with console.status(f"[dim]{label}...[/dim]", spinner="dots"):
608
+ try:
609
+ data = await _do_wf()
610
+ except Exception as e:
611
+ _print_error(str(e), "tool"); return
612
+ else:
613
+ print(label)
614
+ try:
615
+ data = await _do_wf()
616
+ except Exception as e:
617
+ _print_error(str(e), "tool"); return
618
+
619
+ summary = data.get("summary", data)
620
+ folds = data.get("folds", [])
621
+ verdict = summary.get("verdict", "?")
622
+ verdict_color = "green" if verdict == "PASS" else "red"
623
+
624
+ if HAS_RICH:
625
+ from rich.table import Table
626
+ # Summary
627
+ console.print(f"\n[bold]{symbol} · {strategy} · {method}[/bold] Verdict: [bold {verdict_color}]{verdict}[/bold {verdict_color}]")
628
+ console.print(f" Folds: {summary.get('n_folds')} "
629
+ f"Avg OOS Sharpe: [bold]{summary.get('avg_oos_sharpe', 0):.3f}[/bold] "
630
+ f"Consistency: {summary.get('consistency_ratio_pct', 0):.0f}% "
631
+ f"Robustness: {summary.get('robustness_score', 0):.3f} "
632
+ f"p-value: {summary.get('p_value', 1):.4f}")
633
+ # Fold table
634
+ if folds:
635
+ tbl = Table(title="Fold Results", show_header=True, header_style="bold dim")
636
+ for col in ["Fold", "Test Period", "OOS Return", "OOS Sharpe", "OOS MaxDD", "Win%"]:
637
+ tbl.add_column(col, justify="right")
638
+ for f in folds[:12]:
639
+ ret = f.get("test_return_pct", 0)
640
+ tbl.add_row(
641
+ str(f.get("fold_id", "")),
642
+ f.get("test_period", ""),
643
+ f"{'+'if ret>=0 else ''}{ret:.1f}%",
644
+ f"{f.get('test_sharpe', 0):.3f}",
645
+ f"{f.get('test_max_drawdown_pct', 0):.1f}%",
646
+ f"{f.get('test_win_rate_pct', 0):.0f}%",
647
+ )
648
+ console.print(tbl)
649
+ else:
650
+ print(f"Verdict: {verdict} Folds: {summary.get('n_folds')} Avg OOS Sharpe: {summary.get('avg_oos_sharpe',0):.3f}")
651
+
652
+ async def cmd_auto_strategy(self, args: str):
653
+ """AI strategy auto-optimization loop (unique to Aria).
654
+
655
+ Generates a strategy, runs backtest, reads results, iterates until
656
+ the target metric is reached or max rounds exhausted.
657
+
658
+ Usage:
659
+ /auto-strategy momentum SPY
660
+ /auto-strategy momentum SPY --target sharpe=1.5
661
+ /auto-strategy meanrev AAPL --target sharpe=1.2 --rounds 3
662
+ """
663
+ import re as _re, time as _time
664
+
665
+ parts = args.split()
666
+ strategy_type = parts[0].lower() if parts else "momentum"
667
+ symbol = parts[1].upper() if len(parts) > 1 else "SPY"
668
+ target_sharpe = 1.0
669
+ max_rounds = 3
670
+ for p in parts[2:]:
671
+ m = _re.match(r"--target\s*sharpe=([0-9.]+)", p)
672
+ if m:
673
+ target_sharpe = float(m.group(1))
674
+ m = _re.match(r"--rounds=?([0-9]+)", p)
675
+ if m:
676
+ max_rounds = int(m.group(1))
677
+
678
+ if HAS_RICH:
679
+ console.print()
680
+ console.print(f" [bold cyan]🔄 策略自动优化[/bold cyan] [dim]{strategy_type} / {symbol} 目标 Sharpe≥{target_sharpe} 最多{max_rounds}轮[/dim]")
681
+ console.print()
682
+
683
+ best_sharpe = 0.0
684
+ best_version = None
685
+
686
+ for round_num in range(1, max_rounds + 1):
687
+ console.print(f" [bold]第 {round_num}/{max_rounds} 轮[/bold]") if HAS_RICH else print(f" Round {round_num}/{max_rounds}")
688
+
689
+ # ── Step 1: Generate strategy code ──────────────────────────────
690
+ feedback_ctx = ""
691
+ if round_num > 1 and best_version:
692
+ feedback_ctx = (
693
+ f"\n\nPrevious backtest Sharpe={best_sharpe:.2f} (target={target_sharpe})."
694
+ " Modify the strategy to improve Sharpe: adjust lookback period, "
695
+ "add momentum filter, tighten stop-loss, or change position sizing."
696
+ )
697
+
698
+ gen_prompt = (
699
+ f"Generate a complete, self-contained Python backtest strategy script.\n"
700
+ f"Strategy type: {strategy_type}\n"
701
+ f"Symbol: {symbol}\n"
702
+ f"Requirements:\n"
703
+ f"1. Use yfinance to download 2 years of daily OHLCV data\n"
704
+ f"2. Implement the {strategy_type} strategy with clear entry/exit signals\n"
705
+ f"3. Simulate trades: track portfolio value, returns, Sharpe ratio\n"
706
+ f"4. Print EXACTLY this at the end (machine-parseable):\n"
707
+ f" BACKTEST_RESULT: sharpe=X.XX annual_return=X.XX% max_drawdown=X.XX% trades=N\n"
708
+ f"5. All code in one file, no external dependencies except yfinance/pandas/numpy\n"
709
+ f"{feedback_ctx}\n"
710
+ f"Output ONLY the Python code in ```python``` fences."
711
+ )
712
+
713
+ _fname = f"auto_strat_{strategy_type}_{symbol}_r{round_num}_{int(_time.time())}.py"
714
+ from artifacts import user_generated_dir as _user_generated_dir
715
+ _fpath = _user_generated_dir() / _fname
716
+
717
+ console.print(f" [dim]生成策略代码...[/dim]") if HAS_RICH else print(" Generating strategy...")
718
+ await self.terminal.send_message(gen_prompt)
719
+
720
+ # Extract code from last response
721
+ last_ai = next(
722
+ (m["content"] for m in reversed(self.terminal.conversation)
723
+ if m.get("role") == "assistant"), ""
724
+ )
725
+ import re as _re2
726
+ py_blocks = _re2.findall(r"```python\n(.*?)```", last_ai, _re2.DOTALL)
727
+ if not py_blocks:
728
+ # fallback: grab after fence
729
+ m = _re2.search(r"```python\n(.*)", last_ai, _re2.DOTALL)
730
+ if m:
731
+ py_blocks = [m.group(1)]
732
+
733
+ if not py_blocks:
734
+ console.print(" [yellow]⚠ 未生成代码,跳过本轮[/yellow]") if HAS_RICH else print(" No code generated, skipping")
735
+ continue
736
+
737
+ code = py_blocks[-1].strip()
738
+ _tool_write_file({"path": str(_fpath), "content": code, "_skip_confirm": True})
739
+ console.print(f" [dim]策略已保存: {_fpath.name}[/dim]") if HAS_RICH else print(f" Saved: {_fpath.name}")
740
+
741
+ # ── Step 2: Run backtest ─────────────────────────────────────────
742
+ console.print(f" [dim]运行回测...[/dim]") if HAS_RICH else print(" Running backtest...")
743
+ bt_result = _tool_run_command({
744
+ "command": f"python3 {_fpath}",
745
+ "timeout": 120,
746
+ })
747
+ stdout = bt_result.get("data", {}).get("stdout", "") or ""
748
+ stderr = bt_result.get("data", {}).get("stderr", "") or ""
749
+
750
+ # ── Step 3: Parse backtest metrics ──────────────────────────────
751
+ sharpe = 0.0
752
+ ann_return = 0.0
753
+ max_dd = 0.0
754
+ n_trades = 0
755
+ m = _re2.search(r"BACKTEST_RESULT:.*?sharpe=([0-9.-]+)", stdout)
756
+ if m:
757
+ sharpe = float(m.group(1))
758
+ m = _re2.search(r"annual_return=([0-9.-]+)%", stdout)
759
+ if m:
760
+ ann_return = float(m.group(1))
761
+ m = _re2.search(r"max_drawdown=([0-9.-]+)%", stdout)
762
+ if m:
763
+ max_dd = float(m.group(1))
764
+ m = _re2.search(r"trades=([0-9]+)", stdout)
765
+ if m:
766
+ n_trades = int(m.group(1))
767
+
768
+ # Update best
769
+ if sharpe > best_sharpe:
770
+ best_sharpe = sharpe
771
+ best_version = _fpath
772
+
773
+ # Display round result
774
+ sharpe_color = "green" if sharpe >= target_sharpe else ("yellow" if sharpe > 0 else "red")
775
+ if HAS_RICH:
776
+ console.print(
777
+ f" [dim]回测结果:[/dim] "
778
+ f"Sharpe=[{sharpe_color}]{sharpe:.2f}[/{sharpe_color}] "
779
+ f"年化={ann_return:.1f}% "
780
+ f"最大回撤={max_dd:.1f}% "
781
+ f"交易次数={n_trades}"
782
+ )
783
+ else:
784
+ print(f" Backtest: Sharpe={sharpe:.2f} Return={ann_return:.1f}% MaxDD={max_dd:.1f}% Trades={n_trades}")
785
+
786
+ if stderr and "Error" in stderr:
787
+ console.print(f" [red]执行错误: {stderr[:200]}[/red]") if HAS_RICH else print(f" Error: {stderr[:200]}")
788
+
789
+ # ── Step 4: Check convergence ────────────────────────────────────
790
+ if sharpe >= target_sharpe:
791
+ console.print(f"\n [green]✅ 目标达成!Sharpe={sharpe:.2f} ≥ {target_sharpe}[/green]") if HAS_RICH else print(f"\n ✓ Target reached: Sharpe={sharpe:.2f}")
792
+ break
793
+ elif round_num < max_rounds:
794
+ console.print(f" [dim]Sharpe={sharpe:.2f} < 目标{target_sharpe},继续优化...[/dim]\n") if HAS_RICH else print(f" Sharpe={sharpe:.2f} < {target_sharpe}, optimizing...\n")
795
+
796
+ # ── Summary ──────────────────────────────────────────────────────────
797
+ if HAS_RICH:
798
+ console.print()
799
+ 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'}]")
800
+ if best_version:
801
+ console.print(f" 最优策略文件: [dim]{best_version}[/dim]")
802
+ console.print(f" [dim]运行: python3 {best_version}[/dim]")
803
+ console.print()
804
+ else:
805
+ print(f"\n Best Sharpe={best_sharpe:.2f} File: {best_version}")
806
+
807
+ async def cmd_factor_lab(self, args: str):
808
+ """Factor analysis workstation — compute IC, ICIR, factor returns (Aria exclusive).
809
+
810
+ Usage:
811
+ /factor-lab AAPL
812
+ /factor-lab QQQ --days 252
813
+ /factor-lab SPY --factors momentum,value,quality
814
+ """
815
+ import re as _re
816
+
817
+ parts = args.split()
818
+ symbol = parts[0].upper() if parts else "SPY"
819
+ days = 252
820
+ for p in parts[1:]:
821
+ m = _re.match(r"--days=?(\d+)", p)
822
+ if m:
823
+ days = int(m.group(1))
824
+
825
+ if HAS_RICH:
826
+ console.print()
827
+ console.print(f" [bold cyan]🔬 因子分析工作台[/bold cyan] [dim]{symbol} {days}天数据[/dim]")
828
+ console.print()
829
+
830
+ if not _HAS_MDC:
831
+ console.print("[red]需要 market_data_client 模块[/red]") if HAS_RICH else print("market_data_client not available")
832
+ return
833
+
834
+ try:
835
+ import numpy as np
836
+ import pandas as pd
837
+
838
+ mdc = _get_mdc()
839
+
840
+ # ── Fetch data ────────────────────────────────────────────────────
841
+ console.print(" [dim]拉取行情数据...[/dim]") if HAS_RICH else print(" Fetching data...")
842
+ hist = mdc.history(symbol, days=days)
843
+ if not hist.get("success") or not hist.get("data"):
844
+ console.print(f"[red]无法获取 {symbol} 历史数据[/red]") if HAS_RICH else print(f"No data for {symbol}")
845
+ return
846
+
847
+ df = pd.DataFrame(hist["data"])
848
+ df["close"] = pd.to_numeric(df["close"], errors="coerce")
849
+ df["volume"] = pd.to_numeric(df.get("volume", pd.Series()), errors="coerce")
850
+ df = df.dropna(subset=["close"])
851
+ close = df["close"]
852
+ returns = close.pct_change().dropna()
853
+
854
+ # ── Compute factors ───────────────────────────────────────────────
855
+ factors: dict = {}
856
+
857
+ # 1. Momentum (1M, 3M, 6M, 12M)
858
+ for months, label in [(21, "Mom1M"), (63, "Mom3M"), (126, "Mom6M"), (252, "Mom12M")]:
859
+ if len(close) > months:
860
+ factors[label] = close.pct_change(months)
861
+
862
+ # 2. Mean Reversion (short-term)
863
+ if len(close) > 5:
864
+ factors["MeanRev5D"] = -close.pct_change(5)
865
+
866
+ # 3. Volatility (annualized)
867
+ if len(returns) > 20:
868
+ factors["Vol20D"] = returns.rolling(20).std() * np.sqrt(252)
869
+
870
+ # 4. Volume trend
871
+ if "volume" in df.columns and df["volume"].notna().sum() > 20:
872
+ vol_series = df["volume"].astype(float)
873
+ factors["VolTrend"] = vol_series.pct_change(20)
874
+
875
+ # 5. RSI factor
876
+ delta = close.diff()
877
+ gain = delta.clip(lower=0).rolling(14).mean()
878
+ loss = (-delta.clip(upper=0)).rolling(14).mean()
879
+ rs = gain / loss.replace(0, np.nan)
880
+ factors["RSI14"] = 100 - 100 / (1 + rs)
881
+
882
+ # ── Compute IC (Information Coefficient) for each factor ──────────
883
+ # IC = correlation between factor value at t and next-period return
884
+ fwd_returns = returns.shift(-1) # 1-day forward return
885
+
886
+ ic_results = {}
887
+ for fname, fseries in factors.items():
888
+ try:
889
+ aligned = pd.concat([fseries, fwd_returns], axis=1).dropna()
890
+ aligned.columns = ["factor", "fwd"]
891
+ if len(aligned) < 20:
892
+ continue
893
+ ic = aligned["factor"].corr(aligned["fwd"])
894
+ if np.isnan(ic):
895
+ continue
896
+ # Rolling IC (window=20) — compute manually to avoid rolling.apply issues
897
+ roll_ics = []
898
+ for start in range(0, len(aligned) - 20, 5):
899
+ chunk = aligned.iloc[start:start + 20]
900
+ chunk_ic = chunk["factor"].corr(chunk["fwd"])
901
+ if not np.isnan(chunk_ic):
902
+ roll_ics.append(chunk_ic)
903
+ icir = ic / (np.std(roll_ics) + 1e-9) if len(roll_ics) >= 3 else 0.0
904
+ ic_results[fname] = {"ic": ic, "icir": float(icir), "abs_ic": abs(ic)}
905
+ except Exception:
906
+ continue
907
+
908
+ # ── Current factor values (latest bar) ───────────────────────────
909
+ latest = {fname: float(fseries.dropna().iloc[-1]) if not fseries.dropna().empty else None
910
+ for fname, fseries in factors.items()}
911
+
912
+ # ── Display results ───────────────────────────────────────────────
913
+ if HAS_RICH:
914
+ console.print(f" [bold]{symbol}[/bold] [dim]当前价: {close.iloc[-1]:.2f} 数据: {len(df)}天[/dim]")
915
+ console.print()
916
+ console.print(" [bold]因子分析[/bold]")
917
+ console.print()
918
+ console.print(f" [dim]{'因子':<14s}{'IC':>8s}{'|IC|':>8s}{'ICIR':>8s}{'当前值':>12s} 信号[/dim]")
919
+ console.print(" " + "─" * 60)
920
+ for fname, metrics in sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"])):
921
+ ic = metrics["ic"]
922
+ icir = metrics["icir"]
923
+ curr = latest.get(fname)
924
+ curr_str = f"{curr:.3f}" if curr is not None else "N/A"
925
+ signal = ""
926
+ if abs(ic) > 0.03:
927
+ signal = "↑ 看多" if ic > 0 else "↓ 看空"
928
+ ic_color = "green" if ic > 0.03 else ("red" if ic < -0.03 else "dim")
929
+ console.print(
930
+ f" [{ic_color}]{fname:<14s}[/{ic_color}]"
931
+ f"[{ic_color}]{ic:>8.3f}[/{ic_color}]"
932
+ f"{abs(ic):>8.3f}"
933
+ f"{icir:>8.2f}"
934
+ f"{curr_str:>12s}"
935
+ f" [dim]{signal}[/dim]"
936
+ )
937
+ console.print()
938
+ # AI interpretation
939
+ top_factors = sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"]))[:3]
940
+ if top_factors:
941
+ console.print(" [bold]AI 解读[/bold]")
942
+ fac_summary = ", ".join(f"{f}(IC={m['ic']:.3f})" for f, m in top_factors)
943
+ console.print(f" [dim]最有效因子: {fac_summary}[/dim]")
944
+ console.print(f" [dim]使用 /deep-analysis {symbol} 获取完整 AI 投研分析[/dim]")
945
+ console.print()
946
+ else:
947
+ print(f" {symbol} Factor Analysis ({len(df)} days)")
948
+ print(f" {'Factor':<14} {'IC':>8} {'|IC|':>8} {'ICIR':>8} {'Current':>12}")
949
+ for fname, metrics in sorted(ic_results.items(), key=lambda x: -abs(x[1]["ic"])):
950
+ curr = latest.get(fname)
951
+ curr_str = f"{curr:.3f}" if curr is not None else "N/A"
952
+ print(f" {fname:<14} {metrics['ic']:>8.3f} {abs(metrics['ic']):>8.3f} {metrics['icir']:>8.2f} {curr_str:>12}")
953
+
954
+ except ImportError as e:
955
+ console.print(f"[red]需要 numpy/pandas: {e}[/red]") if HAS_RICH else print(f"Missing: {e}")
956
+ except Exception as e:
957
+ console.print(f"[red]因子分析失败: {e}[/red]") if HAS_RICH else print(f"Error: {e}")
958
+
959
+ def _scaffold_with_llm(self, project_name: str, description: str, base_dir) -> None:
960
+ """Call the configured LLM to generate a custom project structure and write files."""
961
+ import json, urllib.request, textwrap, pathlib
962
+
963
+ ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
964
+ model = self.terminal.config.get("model", "qwen2.5:7b")
965
+
966
+ _SCAFFOLD_SYS = (
967
+ "You are a project scaffolding assistant. Output ONLY valid JSON — no markdown, no explanation.\n"
968
+ "Schema:\n"
969
+ '{"description": "one-line summary", "entry": "main.py", '
970
+ '"files": {"relative/path.py": "file content", ...}}\n'
971
+ "CRITICAL JSON rules:\n"
972
+ r'- Inside string values use \n for newlines (backslash-n), NEVER literal newlines.'
973
+ "\n"
974
+ r'- Inside string values use \" for double quotes, \\ for backslashes.'
975
+ "\n"
976
+ "- 3–8 files total. Content must be complete and runnable.\n"
977
+ "- Always include: main entry point, requirements.txt, README.md\n"
978
+ "- requirements.txt: one package per line. README.md: install + usage.\n"
979
+ "- No markdown code fences. Raw JSON only."
980
+ )
981
+ _SCAFFOLD_USER = (
982
+ f"Project name: {project_name}\n"
983
+ f"Description: {description}\n"
984
+ "Generate the complete file structure."
985
+ )
986
+
987
+ if HAS_RICH:
988
+ console.print(f"\n [#C08050]⏺[/#C08050] [bold]LLM 生成项目结构[/bold] [dim]{description}[/dim]")
989
+ else:
990
+ print(f"\n⏺ 生成项目结构: {description}")
991
+
992
+ payload = {
993
+ "model": model,
994
+ "messages": [
995
+ {"role": "system", "content": _SCAFFOLD_SYS},
996
+ {"role": "user", "content": _SCAFFOLD_USER},
997
+ ],
998
+ "stream": False,
999
+ "options": {"temperature": 0.2, "num_predict": 4096},
1000
+ }
1001
+ try:
1002
+ req = urllib.request.Request(
1003
+ ollama_url.rstrip("/") + "/api/chat",
1004
+ data=json.dumps(payload).encode(),
1005
+ headers={"Content-Type": "application/json"},
1006
+ )
1007
+ with urllib.request.urlopen(req, timeout=60) as r:
1008
+ data = json.loads(r.read())
1009
+ raw = data.get("message", {}).get("content", "").strip()
1010
+ except Exception as e:
1011
+ msg = f"LLM 调用失败: {e}"
1012
+ console.print(f" [red]{msg}[/red]") if HAS_RICH else print(f" {msg}")
1013
+ return
1014
+
1015
+ # Strip accidental markdown fences
1016
+ import re as _re
1017
+ raw = _re.sub(r'^```[a-z]*\n?', '', raw).rstrip('`').strip()
1018
+
1019
+ def _parse_scaffold_json(text: str):
1020
+ """Try several strategies to extract valid JSON from LLM output."""
1021
+ # Strategy 1: strict parse
1022
+ try:
1023
+ return json.loads(text)
1024
+ except json.JSONDecodeError:
1025
+ pass
1026
+ # Strategy 2: replace literal newlines inside string values
1027
+ try:
1028
+ # Escape literal newlines that appear inside JSON string values
1029
+ fixed = _re.sub(
1030
+ r'("(?:[^"\\]|\\.)*")',
1031
+ lambda m: m.group().replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t'),
1032
+ text,
1033
+ )
1034
+ return json.loads(fixed)
1035
+ except Exception:
1036
+ pass
1037
+ # Strategy 3: find outermost {...} block
1038
+ m = _re.search(r'\{.*\}', text, _re.DOTALL)
1039
+ if m:
1040
+ try:
1041
+ return json.loads(m.group())
1042
+ except Exception:
1043
+ # Strategy 4: same but with newline escaping
1044
+ try:
1045
+ blob = m.group()
1046
+ fixed = _re.sub(
1047
+ r'("(?:[^"\\]|\\.)*")',
1048
+ lambda mx: mx.group().replace('\n', '\\n').replace('\r', '\\r'),
1049
+ blob,
1050
+ )
1051
+ return json.loads(fixed)
1052
+ except Exception:
1053
+ pass
1054
+ return None
1055
+
1056
+ structure = _parse_scaffold_json(raw)
1057
+ if not structure or "files" not in structure:
1058
+ msg = "LLM 未返回有效 JSON 结构,请重试或使用 --template"
1059
+ console.print(f" [red]{msg}[/red]") if HAS_RICH else print(f" {msg}")
1060
+ return
1061
+
1062
+ files: dict = structure["files"]
1063
+ proj_desc = structure.get("description", description)
1064
+ entry = structure.get("entry", "main.py")
1065
+
1066
+ # ── Preview ───────────────────────────────────────────────────────────
1067
+ if HAS_RICH:
1068
+ console.print(f" [green]✓[/green] [dim]{proj_desc}[/dim]")
1069
+ console.print(f"\n [dim]{base_dir.name}/[/dim]")
1070
+ for fname, fcontent in files.items():
1071
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1072
+ console.print(f" [dim] ├── {fname:<26s}[/dim] {lines} lines")
1073
+ console.print()
1074
+ choice = console.input(
1075
+ " [bold]Create these files?[/bold] [dim]\\[y=all / n=cancel / r=review each][/dim] "
1076
+ ).strip().lower()
1077
+ else:
1078
+ print(f"\n {base_dir.name}/")
1079
+ for fname, fcontent in files.items():
1080
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1081
+ print(f" ├── {fname:<26s} {lines} lines")
1082
+ choice = input(" Create these files? [y/all / n=cancel / r=review each] ").strip().lower()
1083
+
1084
+ if choice in ("n", "no"):
1085
+ console.print("[dim]取消。[/dim]") if HAS_RICH else print("Cancelled.")
1086
+ return
1087
+
1088
+ approve_each = choice in ("r", "review")
1089
+ created, skipped = [], []
1090
+
1091
+ for fname, fcontent in files.items():
1092
+ target = pathlib.Path(base_dir) / fname
1093
+ target.parent.mkdir(parents=True, exist_ok=True)
1094
+
1095
+ if approve_each:
1096
+ if HAS_RICH:
1097
+ console.print(f"\n [dim]{fname}[/dim] ({fcontent.count(chr(10))+1} lines)")
1098
+ sub = console.input(" [dim]写入? [y/n] [/dim]").strip().lower()
1099
+ else:
1100
+ print(f"\n {fname} ({fcontent.count(chr(10))+1} lines)")
1101
+ sub = input(" 写入? [y/n] ").strip().lower()
1102
+ if sub not in ("y", "yes", ""):
1103
+ skipped.append(fname)
1104
+ continue
1105
+
1106
+ result = _tool_write_file({"path": str(target), "content": fcontent, "_skip_confirm": True})
1107
+ if result["success"]:
1108
+ created.append(fname)
1109
+ else:
1110
+ err = result.get("error", "?")
1111
+ console.print(f" [red]Failed {fname}: {err}[/red]") if HAS_RICH else print(f" Failed {fname}: {err}")
1112
+
1113
+ if HAS_RICH:
1114
+ console.print()
1115
+ if created:
1116
+ console.print(f" [green]✓[/green] 创建 {len(created)} 个文件 → [bold]{base_dir}[/bold]")
1117
+ for f in created:
1118
+ console.print(f" [dim]{f}[/dim]")
1119
+ if skipped:
1120
+ console.print(f" [dim]跳过: {', '.join(skipped)}[/dim]")
1121
+ console.print(f"\n [dim]启动: cd \"{base_dir}\" && python3 {entry}[/dim]\n")
1122
+ else:
1123
+ print(f"\n创建 {len(created)} 个文件 → {base_dir}")
1124
+ if skipped:
1125
+ print(f"跳过: {', '.join(skipped)}")
1126
+ print(f"启动: cd \"{base_dir}\" && python3 {entry}")
1127
+
1128
+ def cmd_scaffold(self, args: str):
1129
+ """Generate a project folder structure with files, with user approval.
1130
+
1131
+ Usage:
1132
+ /scaffold <project_name> → blank template
1133
+ /scaffold <project_name> <description...> → LLM generates custom structure
1134
+ /scaffold <project_name> --template analysis → fixed finance template
1135
+ /scaffold <project_name> --template strategy
1136
+ /scaffold <project_name> --template pipeline
1137
+
1138
+ Examples:
1139
+ /scaffold my-api FastAPI REST API with JWT auth and PostgreSQL
1140
+ /scaffold price-alert CLI tool that monitors stock prices and sends alerts
1141
+ /scaffold aapl-analysis --template analysis
1142
+ """
1143
+ import textwrap
1144
+
1145
+ parts = args.strip().split()
1146
+ if not parts:
1147
+ if HAS_RICH:
1148
+ console.print("[dim]Usage: /scaffold <name> [description] | [--template analysis|strategy|pipeline|blank][/dim]")
1149
+ console.print("[dim]Examples:[/dim]")
1150
+ console.print("[dim] /scaffold my-api FastAPI REST API with JWT auth[/dim]")
1151
+ console.print("[dim] /scaffold price-bot CLI tool that monitors stock prices[/dim]")
1152
+ console.print("[dim] /scaffold aapl-analysis --template analysis[/dim]")
1153
+ else:
1154
+ print("Usage: /scaffold <name> [description] | [--template analysis|strategy|pipeline|blank]")
1155
+ return
1156
+
1157
+ # Parse project name, template flag, and optional description
1158
+ project_name = parts[0]
1159
+ template = None
1160
+ description = ""
1161
+ if "--template" in parts:
1162
+ idx = parts.index("--template")
1163
+ if idx + 1 < len(parts):
1164
+ template = parts[idx + 1]
1165
+ # remaining words before --template are ignored
1166
+ elif len(parts) > 1:
1167
+ description = " ".join(parts[1:]) # everything after name = LLM description
1168
+
1169
+ # Resolve base directory under the user's local Aria Code workspace.
1170
+ # Generated strategy/code projects must not silently land in the source repo.
1171
+ from artifacts import user_projects_dir as _user_projects_dir
1172
+ base_dir = _user_projects_dir() / project_name
1173
+
1174
+ # ── LLM-generated scaffold (when user gives a description) ────────────
1175
+ if description and not template:
1176
+ self._scaffold_with_llm(project_name, description, base_dir)
1177
+ return
1178
+
1179
+ # Fallback to blank when no template and no description
1180
+ if template is None:
1181
+ template = "blank"
1182
+
1183
+ # Built-in templates
1184
+ TEMPLATES = {
1185
+ "analysis": {
1186
+ "description": "Stock/asset analysis project",
1187
+ "files": {
1188
+ "main.py": textwrap.dedent("""\
1189
+ #!/usr/bin/env python3
1190
+ \"\"\"
1191
+ {project} — market analysis entry point.
1192
+ Usage: python3 main.py AAPL
1193
+ \"\"\"
1194
+ import sys
1195
+ import os
1196
+ import numpy as np
1197
+ import pandas as pd
1198
+ import yfinance as yf
1199
+ import matplotlib; matplotlib.use('Agg')
1200
+ import matplotlib.pyplot as plt
1201
+ from analysis import run_analysis
1202
+ from report import generate_report
1203
+
1204
+ if __name__ == "__main__":
1205
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "AAPL"
1206
+ data = run_analysis(symbol)
1207
+ generate_report(symbol, data)
1208
+ """),
1209
+ "analysis.py": textwrap.dedent("""\
1210
+ \"\"\"Core analysis logic for {project}.\"\"\"
1211
+ import numpy as np
1212
+ import pandas as pd
1213
+ import yfinance as yf
1214
+
1215
+
1216
+ def run_analysis(symbol: str, period: str = "1y") -> dict:
1217
+ ticker = yf.Ticker(symbol)
1218
+ hist = ticker.history(period=period, auto_adjust=True, progress=False)
1219
+ if hist.empty:
1220
+ raise ValueError(f"No data for {{symbol}}")
1221
+ hist.columns = hist.columns.droplevel(1) if hasattr(hist.columns, 'droplevel') and hist.columns.nlevels > 1 else hist.columns
1222
+ close = hist["Close"]
1223
+ returns = close.pct_change().dropna()
1224
+ sma20 = close.rolling(20).mean()
1225
+ sma50 = close.rolling(50).mean()
1226
+ rsi = _calc_rsi(close)
1227
+ return {{
1228
+ "symbol": symbol,
1229
+ "current_price": round(float(close.iloc[-1]), 2),
1230
+ "sma20": round(float(sma20.iloc[-1]), 2),
1231
+ "sma50": round(float(sma50.iloc[-1]), 2),
1232
+ "rsi": round(float(rsi.iloc[-1]), 1),
1233
+ "annual_return": round(float(returns.mean() * 252), 4),
1234
+ "volatility": round(float(returns.std() * (252 ** 0.5)), 4),
1235
+ "hist": hist,
1236
+ "returns": returns,
1237
+ }}
1238
+
1239
+
1240
+ def _calc_rsi(close: pd.Series, period: int = 14) -> pd.Series:
1241
+ delta = close.diff()
1242
+ gain = delta.clip(lower=0).rolling(period).mean()
1243
+ loss = (-delta.clip(upper=0)).rolling(period).mean()
1244
+ rs = gain / loss.replace(0, float("nan"))
1245
+ return 100 - 100 / (1 + rs)
1246
+ """),
1247
+ "report.py": textwrap.dedent("""\
1248
+ \"\"\"Report generation for {project}.\"\"\"
1249
+ import os
1250
+ import matplotlib; matplotlib.use('Agg')
1251
+ import matplotlib.pyplot as plt
1252
+
1253
+
1254
+ def generate_report(symbol: str, data: dict):
1255
+ fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
1256
+ hist = data["hist"]
1257
+ close = hist["Close"]
1258
+ # Price + SMAs
1259
+ axes[0].plot(close.index, close, label="Close", color="#C08050", linewidth=1.5)
1260
+ axes[0].plot(close.index, hist["Close"].rolling(20).mean(), label="SMA20", color="#2AE8A5", linewidth=1)
1261
+ axes[0].plot(close.index, hist["Close"].rolling(50).mean(), label="SMA50", color="#EF4444", linewidth=1)
1262
+ axes[0].set_title(f"{{symbol}} — Price & Moving Averages", fontsize=14)
1263
+ axes[0].legend(); axes[0].grid(alpha=0.3)
1264
+ # Volume
1265
+ axes[1].bar(hist.index, hist["Volume"], color="#C08050", alpha=0.5, label="Volume")
1266
+ axes[1].set_title("Volume"); axes[1].grid(alpha=0.3)
1267
+ plt.tight_layout()
1268
+ os.makedirs("outputs", exist_ok=True)
1269
+ out = os.path.abspath(os.path.join("outputs", f"{symbol}_analysis.png"))
1270
+ plt.savefig(out, dpi=150, bbox_inches="tight")
1271
+ plt.close()
1272
+ print(f"Chart saved: {{out}}")
1273
+ print(f"Price: ${{data['current_price']}} RSI: {{data['rsi']}} "
1274
+ f"Annual Return: {{data['annual_return']*100:.1f}}% Vol: {{data['volatility']*100:.1f}}%")
1275
+ """),
1276
+ "requirements.txt": "numpy\npandas\nyfinance\nmatplotlib\n",
1277
+ "README.md": textwrap.dedent("""\
1278
+ # {project}
1279
+ Stock analysis project generated by Aria CLI.
1280
+
1281
+ ## Usage
1282
+ ```bash
1283
+ pip3 install -r requirements.txt
1284
+ python3 main.py AAPL
1285
+ ```
1286
+ """),
1287
+ },
1288
+ },
1289
+ "strategy": {
1290
+ "description": "Quant trading strategy with backtesting",
1291
+ "files": {
1292
+ "main.py": textwrap.dedent("""\
1293
+ #!/usr/bin/env python3
1294
+ \"\"\"
1295
+ {project} — backtest entry point.
1296
+ Usage: python3 main.py AAPL 2022-01-01 2024-01-01
1297
+ \"\"\"
1298
+ import sys
1299
+ from strategy import MomentumStrategy
1300
+ from backtest import run_backtest
1301
+
1302
+ if __name__ == "__main__":
1303
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "SPY"
1304
+ start = sys.argv[2] if len(sys.argv) > 2 else "2022-01-01"
1305
+ end = sys.argv[3] if len(sys.argv) > 3 else "2024-01-01"
1306
+ strat = MomentumStrategy(lookback=20)
1307
+ result = run_backtest(strat, symbol, start, end)
1308
+ print(result)
1309
+ """),
1310
+ "strategy.py": textwrap.dedent("""\
1311
+ \"\"\"Strategy definitions for {project}.\"\"\"
1312
+ import pandas as pd
1313
+
1314
+
1315
+ class MomentumStrategy:
1316
+ def __init__(self, lookback: int = 20):
1317
+ self.lookback = lookback
1318
+ self.name = f"Momentum({{lookback}})"
1319
+
1320
+ def generate_signals(self, prices: pd.Series) -> pd.Series:
1321
+ \"\"\"Return +1 (long), -1 (short), 0 (flat) signals.\"\"\"
1322
+ momentum = prices.pct_change(self.lookback)
1323
+ signals = pd.Series(0, index=prices.index)
1324
+ signals[momentum > 0] = 1
1325
+ signals[momentum < 0] = -1
1326
+ return signals.shift(1).fillna(0) # avoid lookahead
1327
+ """),
1328
+ "backtest.py": textwrap.dedent("""\
1329
+ \"\"\"Backtest engine for {project}.\"\"\"
1330
+ import os
1331
+ import numpy as np
1332
+ import pandas as pd
1333
+ import yfinance as yf
1334
+ import matplotlib; matplotlib.use('Agg')
1335
+ import matplotlib.pyplot as plt
1336
+
1337
+
1338
+ def run_backtest(strategy, symbol: str, start: str, end: str) -> dict:
1339
+ ticker = yf.download(symbol, start=start, end=end, auto_adjust=True, progress=False)
1340
+ if ticker.empty:
1341
+ raise ValueError(f"No data for {{symbol}}")
1342
+ prices = ticker["Close"].squeeze()
1343
+ signals = strategy.generate_signals(prices)
1344
+ returns = prices.pct_change().fillna(0)
1345
+ strat_returns = signals * returns
1346
+ equity = (1 + strat_returns).cumprod()
1347
+ bh_equity = (1 + returns).cumprod()
1348
+ # Metrics
1349
+ ann_return = strat_returns.mean() * 252
1350
+ ann_vol = strat_returns.std() * (252 ** 0.5)
1351
+ sharpe = ann_return / ann_vol if ann_vol > 0 else 0
1352
+ max_dd = (equity / equity.cummax() - 1).min()
1353
+ # Plot
1354
+ fig, ax = plt.subplots(figsize=(14, 6))
1355
+ ax.plot(equity.index, equity, label=strategy.name, color="#C08050", linewidth=2)
1356
+ ax.plot(bh_equity.index, bh_equity, label="Buy & Hold", color="#2AE8A5", linewidth=1.5, linestyle="--")
1357
+ ax.set_title(f"{{symbol}} — {{strategy.name}} Backtest"); ax.legend(); ax.grid(alpha=0.3)
1358
+ os.makedirs("outputs", exist_ok=True)
1359
+ out = os.path.abspath(os.path.join("outputs", f"{{symbol}}_backtest.png"))
1360
+ plt.savefig(out, dpi=150, bbox_inches="tight"); plt.close()
1361
+ result = {{
1362
+ "symbol": symbol, "strategy": strategy.name,
1363
+ "ann_return": round(ann_return * 100, 2),
1364
+ "ann_vol": round(ann_vol * 100, 2),
1365
+ "sharpe": round(sharpe, 3),
1366
+ "max_drawdown": round(max_dd * 100, 2),
1367
+ "chart": out,
1368
+ }}
1369
+ print(f"Sharpe: {{result['sharpe']}} Return: {{result['ann_return']}}% "
1370
+ f"MaxDD: {{result['max_drawdown']}}% Chart: {{out}}")
1371
+ return result
1372
+ """),
1373
+ "requirements.txt": "numpy\npandas\nyfinance\nmatplotlib\n",
1374
+ "README.md": textwrap.dedent("""\
1375
+ # {project}
1376
+ Quant strategy backtest project generated by Aria CLI.
1377
+
1378
+ ## Usage
1379
+ ```bash
1380
+ pip3 install -r requirements.txt
1381
+ python3 main.py SPY 2022-01-01 2024-01-01
1382
+ ```
1383
+ """),
1384
+ },
1385
+ },
1386
+ "pipeline": {
1387
+ "description": "Market data pipeline (fetch → process → store)",
1388
+ "files": {
1389
+ "main.py": textwrap.dedent("""\
1390
+ #!/usr/bin/env python3
1391
+ \"\"\"
1392
+ {project} — data pipeline entry point.
1393
+ Usage: python3 main.py AAPL MSFT TSLA
1394
+ \"\"\"
1395
+ import sys
1396
+ from pipeline import DataPipeline
1397
+
1398
+ if __name__ == "__main__":
1399
+ symbols = sys.argv[1:] or ["AAPL", "MSFT", "TSLA"]
1400
+ pipe = DataPipeline(symbols)
1401
+ pipe.run()
1402
+ """),
1403
+ "pipeline.py": textwrap.dedent("""\
1404
+ \"\"\"Data pipeline for {project}.\"\"\"
1405
+ import os
1406
+ import pandas as pd
1407
+ import yfinance as yf
1408
+
1409
+
1410
+ class DataPipeline:
1411
+ def __init__(self, symbols: list, period: str = "1y", output_dir: str = "data"):
1412
+ self.symbols = symbols
1413
+ self.period = period
1414
+ self.output_dir = os.path.expanduser(output_dir)
1415
+ os.makedirs(self.output_dir, exist_ok=True)
1416
+
1417
+ def fetch(self, symbol: str) -> pd.DataFrame:
1418
+ df = yf.download(symbol, period=self.period, auto_adjust=True, progress=False)
1419
+ df.columns = df.columns.droplevel(1) if df.columns.nlevels > 1 else df.columns
1420
+ return df
1421
+
1422
+ def process(self, df: pd.DataFrame) -> pd.DataFrame:
1423
+ df = df.copy()
1424
+ df["Returns"] = df["Close"].pct_change()
1425
+ df["SMA20"] = df["Close"].rolling(20).mean()
1426
+ df["SMA50"] = df["Close"].rolling(50).mean()
1427
+ df["Volatility"] = df["Returns"].rolling(20).std() * (252 ** 0.5)
1428
+ return df.dropna()
1429
+
1430
+ def store(self, symbol: str, df: pd.DataFrame):
1431
+ path = os.path.join(self.output_dir, f"{{symbol}}.csv")
1432
+ df.to_csv(path)
1433
+ print(f" Saved {{len(df)}} rows → {{path}}")
1434
+
1435
+ def run(self):
1436
+ print(f"Running pipeline for: {{self.symbols}}")
1437
+ for symbol in self.symbols:
1438
+ try:
1439
+ raw = self.fetch(symbol)
1440
+ processed = self.process(raw)
1441
+ self.store(symbol, processed)
1442
+ except Exception as e:
1443
+ print(f" Error {{symbol}}: {{e}}")
1444
+ print("Pipeline complete.")
1445
+ """),
1446
+ "requirements.txt": "pandas\nyfinance\n",
1447
+ "README.md": textwrap.dedent("""\
1448
+ # {project}
1449
+ Market data pipeline generated by Aria CLI.
1450
+
1451
+ ## Usage
1452
+ ```bash
1453
+ pip3 install -r requirements.txt
1454
+ python3 main.py AAPL MSFT TSLA
1455
+ # Output CSVs saved to ./data/
1456
+ ```
1457
+ """),
1458
+ },
1459
+ },
1460
+ "blank": {
1461
+ "description": "Blank project scaffold",
1462
+ "files": {
1463
+ "main.py": textwrap.dedent("""\
1464
+ #!/usr/bin/env python3
1465
+ \"\"\"
1466
+ {project} — main entry point.
1467
+ \"\"\"
1468
+ import os
1469
+ import sys
1470
+ import numpy as np
1471
+ import pandas as pd
1472
+
1473
+
1474
+ def main():
1475
+ print("Hello from {project}!")
1476
+
1477
+
1478
+ if __name__ == "__main__":
1479
+ main()
1480
+ """),
1481
+ "requirements.txt": "numpy\npandas\n",
1482
+ "README.md": "# {project}\n\nProject generated by Aria CLI.\n",
1483
+ },
1484
+ },
1485
+ }
1486
+
1487
+ if template not in TEMPLATES:
1488
+ msg = f"Unknown template '{template}'. Available: {', '.join(TEMPLATES)}"
1489
+ console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
1490
+ return
1491
+
1492
+ tmpl = TEMPLATES[template]
1493
+ files = {
1494
+ k: v.format(project=project_name) if isinstance(v, str) else v
1495
+ for k, v in tmpl["files"].items()
1496
+ }
1497
+
1498
+ # ── Preview: show tree + file summaries ──────────────────────────────
1499
+ if HAS_RICH:
1500
+ console.print()
1501
+ console.print(f" [bold]Scaffold:[/bold] [cyan]{project_name}[/cyan] "
1502
+ f"[dim]({tmpl['description']}, {template} template)[/dim]")
1503
+ console.print(f" [dim]Location:[/dim] {base_dir}")
1504
+ console.print()
1505
+ console.print(f" [dim]{base_dir.name}/[/dim]")
1506
+ for fname, fcontent in files.items():
1507
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1508
+ exists_tag = " [yellow](exists)[/yellow]" if (base_dir / fname).exists() else ""
1509
+ console.print(f" [dim] ├── {fname:<24s}[/dim] {lines} lines{exists_tag}")
1510
+ console.print()
1511
+ else:
1512
+ print(f"\nScaffold: {project_name} ({template} template)")
1513
+ print(f"Location: {base_dir}")
1514
+ print(f"\n {base_dir.name}/")
1515
+ for fname, fcontent in files.items():
1516
+ lines = fcontent.count("\n") + 1 if fcontent else 0
1517
+ exists_tag = " (exists)" if (base_dir / fname).exists() else ""
1518
+ print(f" ├── {fname:<24s} {lines} lines{exists_tag}")
1519
+ print()
1520
+
1521
+ # ── Ask: approve all / approve each / cancel ─────────────────────────
1522
+ # In non-interactive mode (-p flag / piped stdin) auto-approve all files.
1523
+ if not sys.stdin.isatty():
1524
+ choice = "y"
1525
+ console.print(" [dim](非交互模式:自动确认创建所有文件)[/dim]") if HAS_RICH else print(" (Auto-approved: non-interactive mode)")
1526
+ elif HAS_RICH:
1527
+ choice = console.input(
1528
+ " [bold]Create these files?[/bold] "
1529
+ "[dim]\\[y=all / n=cancel / r=review each][/dim] "
1530
+ ).strip().lower()
1531
+ else:
1532
+ choice = input(" Create these files? [y=all / n=cancel / r=review each] ").strip().lower()
1533
+
1534
+ if choice in ("n", "no"):
1535
+ console.print("[dim]Scaffold cancelled.[/dim]" if HAS_RICH else "Cancelled.")
1536
+ return
1537
+
1538
+ approve_each = choice in ("r", "review")
1539
+ created, skipped = [], []
1540
+
1541
+ for fname, fcontent in files.items():
1542
+ target = base_dir / fname
1543
+ if approve_each:
1544
+ if HAS_RICH:
1545
+ console.print(f"\n [dim]{fname}[/dim] ({fcontent.count(chr(10))+1} lines)")
1546
+ sub = console.input(
1547
+ " [dim]Write this file? [y/n] [/dim]"
1548
+ ).strip().lower()
1549
+ else:
1550
+ print(f"\n {fname} ({fcontent.count(chr(10))+1} lines)")
1551
+ sub = input(" Write? [y/n] ").strip().lower()
1552
+ if sub not in ("y", "yes", ""):
1553
+ skipped.append(fname)
1554
+ continue
1555
+
1556
+ result = _tool_write_file({"path": str(target), "content": fcontent, "_skip_confirm": True})
1557
+ if result["success"]:
1558
+ created.append(fname)
1559
+ else:
1560
+ err = result.get("error", "?")
1561
+ if HAS_RICH:
1562
+ console.print(f" [red]Failed {fname}: {err}[/red]")
1563
+ else:
1564
+ print(f" Failed {fname}: {err}")
1565
+
1566
+ # ── Summary ───────────────────────────────────────────────────────────
1567
+ if HAS_RICH:
1568
+ console.print()
1569
+ if created:
1570
+ console.print(f" [green]✓[/green] Created {len(created)} file(s) in [bold]{base_dir}[/bold]")
1571
+ for f in created:
1572
+ console.print(f" [dim]{f}[/dim]")
1573
+ if skipped:
1574
+ console.print(f" [dim]Skipped: {', '.join(skipped)}[/dim]")
1575
+ console.print()
1576
+ console.print(f" [dim]Run: cd \"{base_dir}\" && python3 main.py[/dim]")
1577
+ console.print()
1578
+ else:
1579
+ print(f"\nCreated {len(created)} files in {base_dir}")
1580
+ if skipped:
1581
+ print(f"Skipped: {', '.join(skipped)}")
1582
+ print(f"Run: cd \"{base_dir}\" && python3 main.py")
1583
+
1584
+ async def cmd_strategy(self, args: str):
1585
+ """
1586
+ 策略版本管理系统 (Strategy Vault)
1587
+
1588
+ /strategy save [name] [message] — 保存当前对话中最后一段代码
1589
+ /strategy list [name] — 列出所有版本
1590
+ /strategy diff [name] [v1] [v2] — 查看版本差异
1591
+ /strategy load [name] [tag/id] — 加载版本到上下文
1592
+ /strategy review — AI审查+静态检测
1593
+ """
1594
+ if not _HAS_VAULT:
1595
+ console.print(" [yellow]strategy_vault.py 未找到[/yellow]" if HAS_RICH
1596
+ else " strategy_vault.py not found")
1597
+ return
1598
+
1599
+ parts = args.strip().split(None, 3)
1600
+ sub = parts[0].lower() if parts else "list"
1601
+
1602
+ vault = _get_vault()
1603
+
1604
+ # ── save ──────────────────────────────────────────────────────────
1605
+ if sub == "save":
1606
+ # 从对话历史中提取最后一段 Python 代码
1607
+ code = self._extract_last_code()
1608
+ if not code:
1609
+ if HAS_RICH:
1610
+ console.print(" [yellow]未在对话中找到代码块。先让 Aria 生成策略代码。[/yellow]")
1611
+ else:
1612
+ print(" No code found in conversation. Generate strategy code first.")
1613
+ return
1614
+ name = parts[1] if len(parts) > 1 and not parts[1].startswith('"') else "strategy"
1615
+ message = " ".join(parts[2:]).strip('"') if len(parts) > 2 else ""
1616
+ sv = vault.save(code, name=name, message=message)
1617
+ if HAS_RICH:
1618
+ console.print(
1619
+ f"\n [green]✓[/green] 策略已保存 "
1620
+ f"[bold]{sv.name}[/bold] [dim]{sv.version_tag}[/dim] "
1621
+ f"hash={sv.code_hash} {sv.created_at[:16]}"
1622
+ )
1623
+ else:
1624
+ print(f" Saved: {sv.name} {sv.version_tag} ({sv.created_at[:16]})")
1625
+
1626
+ # ── list ──────────────────────────────────────────────────────────
1627
+ elif sub == "list":
1628
+ name = parts[1] if len(parts) > 1 else None
1629
+ if name:
1630
+ versions = vault.list(name)
1631
+ title = f" 策略: {name}"
1632
+ else:
1633
+ # Show all strategies
1634
+ all_names = vault.list_all_names()
1635
+ if not all_names:
1636
+ console.print(" [dim]策略金库为空。使用 /strategy save 保存策略。[/dim]" if HAS_RICH
1637
+ else " Vault is empty.")
1638
+ return
1639
+ if HAS_RICH:
1640
+ console.print("\n [bold]策略金库[/bold]\n")
1641
+ for n in all_names:
1642
+ vs = vault.list(n, limit=3)
1643
+ latest = vs[0] if vs else None
1644
+ if latest:
1645
+ bt = ""
1646
+ if latest.backtest_result:
1647
+ br = latest.backtest_result
1648
+ bt = f" sharpe={br.get('sharpe_ratio','?')} ret={br.get('total_return_pct','?')}%"
1649
+ console.print(
1650
+ f" [bold]{n}[/bold] [dim]{len(vs)}个版本 "
1651
+ f"最新:{latest.version_tag} {latest.created_at[:10]}{bt}[/dim]"
1652
+ )
1653
+ console.print()
1654
+ else:
1655
+ for n in all_names:
1656
+ print(f" {n}")
1657
+ return
1658
+ if not versions:
1659
+ console.print(f" [dim]没有找到策略 '{name}'[/dim]" if HAS_RICH else f" Not found: {name}")
1660
+ return
1661
+ if HAS_RICH:
1662
+ console.print(f"\n [bold]{title}[/bold]\n")
1663
+ for v in versions:
1664
+ bt = ""
1665
+ if v.backtest_result:
1666
+ br = v.backtest_result
1667
+ sharpe = br.get("sharpe_ratio")
1668
+ ret = br.get("total_return_pct")
1669
+ bt = f" [green]sharpe={sharpe:.2f} ret={ret:.1f}%[/green]" if sharpe else ""
1670
+ reviewed = " [dim]✓reviewed[/dim]" if v.review_result else ""
1671
+ msg = f" [dim]{v.message[:50]}[/dim]" if v.message else ""
1672
+ console.print(
1673
+ f" [dim]{v.id:4d}[/dim] [bold]{v.version_tag}[/bold] "
1674
+ f"[dim]{v.created_at[:16]}[/dim]{msg}{bt}{reviewed}"
1675
+ )
1676
+ console.print()
1677
+ else:
1678
+ for v in versions:
1679
+ print(v.summary_line())
1680
+
1681
+ # ── diff ──────────────────────────────────────────────────────────
1682
+ elif sub == "diff":
1683
+ name = parts[1] if len(parts) > 1 else "strategy"
1684
+ tag_a = parts[2] if len(parts) > 2 else None
1685
+ tag_b = parts[3] if len(parts) > 3 else None
1686
+ diff_text = vault.diff(name, tag_a, tag_b)
1687
+ if HAS_RICH:
1688
+ console.print()
1689
+ # Simple color: + lines green, - lines red
1690
+ for line in diff_text.splitlines():
1691
+ if line.startswith("+++") or line.startswith("---"):
1692
+ console.print(f" [bold]{line}[/bold]")
1693
+ elif line.startswith("+"):
1694
+ console.print(f" [green]{line}[/green]")
1695
+ elif line.startswith("-"):
1696
+ console.print(f" [red]{line}[/red]")
1697
+ elif line.startswith("@@"):
1698
+ console.print(f" [cyan]{line}[/cyan]")
1699
+ else:
1700
+ console.print(f" {line}")
1701
+ console.print()
1702
+ else:
1703
+ print(diff_text)
1704
+
1705
+ # ── load ──────────────────────────────────────────────────────────
1706
+ elif sub == "load":
1707
+ name = parts[1] if len(parts) > 1 else "strategy"
1708
+ tag = parts[2] if len(parts) > 2 else None
1709
+ version = vault.load(name, version_tag=tag)
1710
+ if not version:
1711
+ console.print(f" [red]未找到: {name} {tag or '(latest)'}[/red]" if HAS_RICH
1712
+ else f" Not found: {name} {tag}")
1713
+ return
1714
+ # Inject code into conversation context as a user message
1715
+ code_msg = f"以下是策略 {version.name} {version.version_tag} 的代码:\n\n```python\n{version.code}\n```"
1716
+ self.terminal.conversation.append({"role": "assistant", "content": code_msg})
1717
+ if HAS_RICH:
1718
+ console.print(
1719
+ f"\n [green]✓[/green] 已加载 [bold]{version.name} {version.version_tag}[/bold] "
1720
+ f"[dim]{len(version.code)} chars {version.created_at[:16]}[/dim]"
1721
+ )
1722
+ console.print(f" [dim]{version.message}[/dim]" if version.message else "")
1723
+ lines = version.code.count("\n")
1724
+ console.print(f" [dim]代码 {lines} 行已注入上下文,可继续对话修改。[/dim]")
1725
+ else:
1726
+ print(f" Loaded: {version.name} {version.version_tag}")
1727
+
1728
+ # ── review ────────────────────────────────────────────────────────
1729
+ elif sub == "review":
1730
+ name = parts[1] if len(parts) > 1 else "strategy"
1731
+ tag = parts[2] if len(parts) > 2 else None
1732
+ version = vault.load(name, version_tag=tag)
1733
+ if not version:
1734
+ code = self._extract_last_code()
1735
+ if not code:
1736
+ console.print(" [yellow]未找到策略,请先 /strategy save 或生成代码[/yellow]" if HAS_RICH
1737
+ else " No strategy found.")
1738
+ return
1739
+ ver_id = None
1740
+ else:
1741
+ code = version.code
1742
+ ver_id = version.id
1743
+
1744
+ if HAS_RICH:
1745
+ console.print()
1746
+ console.print(" [bold]🔬 策略审查中...[/bold]")
1747
+ console.print()
1748
+
1749
+ ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
1750
+ model = self.terminal.config.get("model", "qwen2.5:7b")
1751
+ bt_result = version.backtest_result if version else None
1752
+
1753
+ import sys
1754
+ def on_token(tok):
1755
+ sys.stdout.write(tok)
1756
+ sys.stdout.flush()
1757
+
1758
+ review = await _ai_review(code, bt_result, ollama_url, model, on_token=on_token)
1759
+
1760
+ # Print static results
1761
+ static = review.get("static", {})
1762
+ if HAS_RICH:
1763
+ console.print()
1764
+ console.print(f"\n [bold]静态检测[/bold] 评级:{static.get('grade','?')} "
1765
+ f"{static.get('summary','')}")
1766
+ for e in static.get("errors", []):
1767
+ console.print(f" [red]❌ {e['detail']}[/red]")
1768
+ for w in static.get("warnings", []):
1769
+ console.print(f" [yellow]⚠️ {w['detail']}[/yellow]")
1770
+ for q in static.get("quality_checks", []):
1771
+ console.print(f" [dim]💡 {q}[/dim]")
1772
+ console.print()
1773
+ else:
1774
+ print(f"\n Static: {static.get('summary','')}")
1775
+
1776
+ if ver_id:
1777
+ vault.save_review(ver_id, review)
1778
+ if HAS_RICH:
1779
+ console.print(" [dim]审查结果已保存到策略金库[/dim]")
1780
+
1781
+ else:
1782
+ if HAS_RICH:
1783
+ console.print(
1784
+ "\n [bold]Strategy Vault 命令[/bold]\n\n"
1785
+ " /strategy save [name] [message] 保存当前代码快照\n"
1786
+ " /strategy list [name] 列出版本历史\n"
1787
+ " /strategy diff [name] [v1] [v2] 查看版本差异\n"
1788
+ " /strategy load [name] [tag] 加载版本到上下文\n"
1789
+ " /strategy review [name] [tag] AI + 静态代码审查\n"
1790
+ )
1791
+ else:
1792
+ print(" Usage: /strategy save|list|diff|load|review [name] [tag]")
1793
+
1794
+ # ── ML 信号组合回测 ──────────────────────────────────────────────────────────
1795
+
1796
+ async def _cmd_ml_signal_backtest(
1797
+ self, symbol_args: list, start_date: str = "2023-01-01",
1798
+ end_date: str = "", capital: float = 1_000_000,
1799
+ ):
1800
+ """
1801
+ /backtest ml [sym1 sym2 ...] [--start YYYY-MM-DD] [--capital N]
1802
+
1803
+ 三策略对比: ML-Weighted / Equal-Weight / Buy-and-Hold
1804
+ 支持 A股(T+1)、港股、美股混合组合。
1805
+ """
1806
+ # ML signal backtest is part of the private Arthera engine (alpha IP).
1807
+ # If a local Arthera checkout is present (dev), make it importable;
1808
+ # otherwise the import below fails and we show a Pro-feature notice.
1809
+ import sys, os
1810
+ _arthera_pkgs = os.environ.get("ARTHERA_ROOT") or os.path.expanduser("~/Desktop/Arthera")
1811
+ _arthera_pkgs = os.path.join(_arthera_pkgs, "packages")
1812
+ if os.path.isdir(_arthera_pkgs) and _arthera_pkgs not in sys.path:
1813
+ sys.path.insert(0, _arthera_pkgs)
1814
+
1815
+ if HAS_RICH:
1816
+ console.print("\n [bold cyan]ML 信号组合回测[/bold cyan] 三策略对比\n")
1817
+ else:
1818
+ print("\n ML 信号组合回测 三策略对比\n")
1819
+
1820
+ # 解析标的列表(去掉标志位)
1821
+ symbols = [s.upper() for s in symbol_args if not s.startswith("--")]
1822
+ if not symbols:
1823
+ symbols = ["600519", "300750", "NVDA", "AAPL"]
1824
+ if HAS_RICH:
1825
+ console.print(f" [dim]未指定标的,使用默认组合: {symbols}[/dim]")
1826
+
1827
+ if HAS_RICH:
1828
+ console.print(f" 标的: [yellow]{' | '.join(symbols)}[/yellow]")
1829
+ console.print(f" 区间: {start_date} → {end_date or '今日'}")
1830
+ console.print(f" 初始资金: {capital:,.0f}\n")
1831
+ console.print(" [dim]正在拉取行情并训练模型,请稍候…[/dim]")
1832
+
1833
+ try:
1834
+ from quant_engine.backtest.ml_signal_backtest import MLSignalBacktest
1835
+
1836
+ bt = MLSignalBacktest(
1837
+ symbols=symbols,
1838
+ initial_cash=capital,
1839
+ rebalance_freq="W",
1840
+ )
1841
+ report = bt.run(start=start_date, end=end_date or "")
1842
+ report.print_report()
1843
+
1844
+ if HAS_RICH:
1845
+ # 额外渲染净值图(纯 ASCII sparkline)
1846
+ ml_nav = report.ml_strategy.nav_series
1847
+ ew_nav = report.ew_strategy.nav_series
1848
+ if not ml_nav.empty and not ew_nav.empty:
1849
+ console.print("\n [bold]净值走势(最近 40 个交易日)[/bold]")
1850
+ _print_sparkline("ML 权重", ml_nav, "cyan")
1851
+ _print_sparkline("等权基准", ew_nav, "yellow")
1852
+
1853
+ except ImportError:
1854
+ # Moat feature — the ML/alpha engine ships only with the full
1855
+ # Arthera platform, not the open CLI. Degrade with a clear notice.
1856
+ _msg = ("ML 信号回测属于 Arthera 高级引擎(含 ML 选股/alpha 因子),"
1857
+ "开源 CLI 未内置。\n 基础回测可用:/backtest momentum <symbol>")
1858
+ if HAS_RICH:
1859
+ console.print(f" [#C08050]◆ Pro 功能[/#C08050] [dim]{_msg}[/dim]")
1860
+ else:
1861
+ print(f" ◆ Pro 功能 {_msg}")
1862
+ except Exception as e:
1863
+ _print_error(f"ML 回测失败: {e}")
1864
+ import traceback
1865
+ console.print(f" [dim]{traceback.format_exc()}[/dim]") if HAS_RICH else print(traceback.format_exc())
1866
+
1867
+
1868
+ def _print_sparkline(label: str, nav: "pd.Series", color: str = "white", width: int = 40):
1869
+ """打印 ASCII sparkline。"""
1870
+ try:
1871
+ import sys
1872
+ HAS_RICH = "rich" in sys.modules
1873
+ vals = nav.iloc[-width:].values if len(nav) > width else nav.values
1874
+ if len(vals) < 2:
1875
+ return
1876
+ lo, hi = vals.min(), vals.max()
1877
+ chars = "▁▂▃▄▅▆▇█"
1878
+ spark = "".join(chars[min(7, int((v - lo) / (hi - lo + 1e-9) * 8))] for v in vals)
1879
+ change = (vals[-1] / vals[0] - 1) * 100
1880
+ sign = "+" if change >= 0 else ""
1881
+ if HAS_RICH:
1882
+ from rich.console import Console as _C
1883
+ _C().print(f" [{color}]{label:<8}[/{color}] {spark} [{color}]{sign}{change:.2f}%[/{color}]")
1884
+ else:
1885
+ print(f" {label:<8} {spark} {sign}{change:.2f}%")
1886
+ except Exception:
1887
+ pass