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.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- 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
|