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,405 @@
|
|
|
1
|
+
"""DataCommandsMixin — data, alert, correlation, and comparison commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DataCommandsMixin:
|
|
7
|
+
"""Mixin: data analysis and comparison commands."""
|
|
8
|
+
|
|
9
|
+
async def cmd_data(self, args: str):
|
|
10
|
+
"""
|
|
11
|
+
/data sql "SELECT ..." — DuckDB SQL 查询
|
|
12
|
+
/data export [filename] — 导出上次结果到 Excel
|
|
13
|
+
/data load <csv_path> — 加载 CSV 到 DuckDB
|
|
14
|
+
/data tables — 列出已加载的表
|
|
15
|
+
"""
|
|
16
|
+
import asyncio as _asyncio
|
|
17
|
+
loop = _asyncio.get_event_loop()
|
|
18
|
+
parts = args.strip().split(None, 1) if args.strip() else []
|
|
19
|
+
sub = parts[0].lower() if parts else "help"
|
|
20
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from data_analysis_tools import (sql_query, sql_list_tables,
|
|
24
|
+
export_to_excel, load_csv_data)
|
|
25
|
+
except ImportError as e:
|
|
26
|
+
if HAS_RICH:
|
|
27
|
+
console.print(f"[red]data_analysis_tools 未加载: {e}[/red]")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if sub == "sql":
|
|
31
|
+
query = rest.strip().strip('"').strip("'")
|
|
32
|
+
if not query:
|
|
33
|
+
if HAS_RICH:
|
|
34
|
+
console.print("[dim]用法: /data sql \"SELECT ...\"|/dim]")
|
|
35
|
+
return
|
|
36
|
+
if HAS_RICH:
|
|
37
|
+
with console.status("[dim]执行 SQL...[/dim]", spinner="dots"):
|
|
38
|
+
r = await loop.run_in_executor(None, sql_query, {"query": query})
|
|
39
|
+
else:
|
|
40
|
+
r = sql_query({"query": query})
|
|
41
|
+
_render_sql_result(r)
|
|
42
|
+
|
|
43
|
+
elif sub == "export":
|
|
44
|
+
fname = rest.strip() or None
|
|
45
|
+
watchlist = self.terminal.config.get("watchlist", ["AAPL", "MSFT", "SPY"])
|
|
46
|
+
try:
|
|
47
|
+
import yfinance as _yf
|
|
48
|
+
raw = _yf.download(watchlist[:5], period="1mo", progress=False, auto_adjust=True)
|
|
49
|
+
closes = raw["Close"] if hasattr(raw.columns, "levels") else raw
|
|
50
|
+
export_data = {"价格历史": closes.reset_index().to_dict("records")}
|
|
51
|
+
except Exception:
|
|
52
|
+
export_data = {"示例数据": [{"symbol": s, "note": "需 yfinance"} for s in watchlist]}
|
|
53
|
+
p = {"data": export_data, "filename": fname}
|
|
54
|
+
if HAS_RICH:
|
|
55
|
+
with console.status("[dim]生成 Excel...[/dim]", spinner="dots"):
|
|
56
|
+
r = await loop.run_in_executor(None, export_to_excel, p)
|
|
57
|
+
else:
|
|
58
|
+
r = export_to_excel(p)
|
|
59
|
+
if r.get("success"):
|
|
60
|
+
msg = f"✓ 已导出: {r['path']} ({r['total_rows']} 行)"
|
|
61
|
+
if HAS_RICH:
|
|
62
|
+
console.print(f"[green]{msg}[/green]")
|
|
63
|
+
else:
|
|
64
|
+
print(msg)
|
|
65
|
+
else:
|
|
66
|
+
if HAS_RICH:
|
|
67
|
+
console.print(f"[red]{r.get('error')}[/red]")
|
|
68
|
+
|
|
69
|
+
elif sub == "load":
|
|
70
|
+
csv_path = rest.strip()
|
|
71
|
+
if not csv_path:
|
|
72
|
+
if HAS_RICH:
|
|
73
|
+
console.print("[dim]用法: /data load <csv文件路径>[/dim]")
|
|
74
|
+
return
|
|
75
|
+
if HAS_RICH:
|
|
76
|
+
with console.status("[dim]加载 CSV...[/dim]", spinner="dots"):
|
|
77
|
+
r = await loop.run_in_executor(None, load_csv_data, {"path": csv_path})
|
|
78
|
+
else:
|
|
79
|
+
r = load_csv_data({"path": csv_path})
|
|
80
|
+
if r.get("success"):
|
|
81
|
+
if HAS_RICH:
|
|
82
|
+
console.print(f"[green]✓ 已加载 {r['rows']} 行 → 表 {r['table_name']}[/green]")
|
|
83
|
+
console.print(f"[dim]列: {', '.join(r['columns'][:10])}[/dim]")
|
|
84
|
+
console.print(f"[dim]现在可以: /data sql \"SELECT * FROM {r['table_name']} LIMIT 10\"[/dim]")
|
|
85
|
+
else:
|
|
86
|
+
if HAS_RICH:
|
|
87
|
+
console.print(f"[red]{r.get('error')}[/red]")
|
|
88
|
+
|
|
89
|
+
elif sub == "tables":
|
|
90
|
+
r = sql_list_tables()
|
|
91
|
+
if r.get("success"):
|
|
92
|
+
tables = r.get("tables", [])
|
|
93
|
+
if HAS_RICH:
|
|
94
|
+
if tables:
|
|
95
|
+
console.print(f"[bold]已加载表:[/bold] {', '.join(tables)}")
|
|
96
|
+
else:
|
|
97
|
+
console.print("[dim]暂无已加载的表。使用 /data load <csv> 加载数据[/dim]")
|
|
98
|
+
|
|
99
|
+
else:
|
|
100
|
+
if HAS_RICH:
|
|
101
|
+
console.print("[dim]用法: /data [sql|export|load|tables][/dim]")
|
|
102
|
+
console.print("[dim] /data sql \"SELECT * FROM my_table LIMIT 10\"[/dim]")
|
|
103
|
+
console.print("[dim] /data load ~/Desktop/data.csv[/dim]")
|
|
104
|
+
console.print("[dim] /data export my_report.xlsx[/dim]")
|
|
105
|
+
console.print("[dim] /data tables[/dim]")
|
|
106
|
+
|
|
107
|
+
async def cmd_alert(self, args: str):
|
|
108
|
+
"""
|
|
109
|
+
/alert add AAPL gt 200 — 设置预警(gt/lt/cross_up/cross_down)
|
|
110
|
+
/alert list — 列出所有预警
|
|
111
|
+
/alert delete <id> — 删除预警
|
|
112
|
+
/alert check — 检查所有预警状态
|
|
113
|
+
"""
|
|
114
|
+
import asyncio as _asyncio
|
|
115
|
+
loop = _asyncio.get_event_loop()
|
|
116
|
+
parts = args.strip().split() if args.strip() else []
|
|
117
|
+
sub = parts[0].lower() if parts else "list"
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
from data_analysis_tools import (add_price_alert, list_price_alerts,
|
|
121
|
+
delete_price_alert, check_alerts)
|
|
122
|
+
except ImportError as e:
|
|
123
|
+
if HAS_RICH:
|
|
124
|
+
console.print(f"[red]data_analysis_tools 未加载: {e}[/red]")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if sub == "add":
|
|
128
|
+
if len(parts) < 4:
|
|
129
|
+
if HAS_RICH:
|
|
130
|
+
console.print("[dim]用法: /alert add <symbol> <gt|lt|cross_up|cross_down> <price> [备注][/dim]")
|
|
131
|
+
return
|
|
132
|
+
sym = parts[1].upper()
|
|
133
|
+
cond = parts[2].lower()
|
|
134
|
+
try:
|
|
135
|
+
price = float(parts[3])
|
|
136
|
+
except ValueError:
|
|
137
|
+
if HAS_RICH:
|
|
138
|
+
console.print("[red]价格必须是数字[/red]")
|
|
139
|
+
return
|
|
140
|
+
note = " ".join(parts[4:]) if len(parts) > 4 else ""
|
|
141
|
+
r = add_price_alert({"symbol": sym, "condition": cond, "price": price, "note": note})
|
|
142
|
+
if r.get("success"):
|
|
143
|
+
msg = r.get("message", "预警已设置")
|
|
144
|
+
if HAS_RICH:
|
|
145
|
+
console.print(f"[green]✓ {msg}[/green]")
|
|
146
|
+
else:
|
|
147
|
+
print(f"✓ {msg}")
|
|
148
|
+
else:
|
|
149
|
+
if HAS_RICH:
|
|
150
|
+
console.print(f"[red]{r.get('error')}[/red]")
|
|
151
|
+
|
|
152
|
+
elif sub == "list":
|
|
153
|
+
r = list_price_alerts()
|
|
154
|
+
_render_alerts(r)
|
|
155
|
+
|
|
156
|
+
elif sub in ("delete", "del", "remove"):
|
|
157
|
+
alert_id = parts[1] if len(parts) > 1 else ""
|
|
158
|
+
if not alert_id:
|
|
159
|
+
if HAS_RICH:
|
|
160
|
+
console.print("[dim]用法: /alert delete <预警ID>[/dim]")
|
|
161
|
+
return
|
|
162
|
+
r = delete_price_alert({"alert_id": alert_id})
|
|
163
|
+
if r.get("success"):
|
|
164
|
+
if HAS_RICH:
|
|
165
|
+
console.print(f"[green]✓ 已删除预警 {r['deleted_id']}[/green]")
|
|
166
|
+
else:
|
|
167
|
+
if HAS_RICH:
|
|
168
|
+
console.print(f"[red]{r.get('error')}[/red]")
|
|
169
|
+
|
|
170
|
+
elif sub == "check":
|
|
171
|
+
if HAS_RICH:
|
|
172
|
+
with console.status("[dim]检查价格预警...[/dim]", spinner="dots"):
|
|
173
|
+
r = await loop.run_in_executor(None, check_alerts)
|
|
174
|
+
else:
|
|
175
|
+
r = check_alerts()
|
|
176
|
+
triggered = r.get("triggered", [])
|
|
177
|
+
if triggered:
|
|
178
|
+
if HAS_RICH:
|
|
179
|
+
console.print(f"[bold yellow]🔔 {len(triggered)} 个预警已触发![/bold yellow]")
|
|
180
|
+
for a in triggered:
|
|
181
|
+
console.print(f" [yellow]{a['symbol']}[/yellow] {a.get('condition','')} "
|
|
182
|
+
f"{a['price']} → 当前 [bold]{a.get('triggered_price','')}[/bold]")
|
|
183
|
+
else:
|
|
184
|
+
msg = r.get("message", "暂无触发的预警")
|
|
185
|
+
if HAS_RICH:
|
|
186
|
+
console.print(f"[dim]{msg}[/dim]")
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
if HAS_RICH:
|
|
190
|
+
console.print("[dim]用法: /alert [add|list|delete|check][/dim]")
|
|
191
|
+
|
|
192
|
+
async def cmd_corr(self, args: str):
|
|
193
|
+
"""/corr AAPL MSFT TSLA SPY [1y|2y|6mo] — 计算相关性矩阵"""
|
|
194
|
+
import asyncio as _asyncio
|
|
195
|
+
loop = _asyncio.get_event_loop()
|
|
196
|
+
parts = args.strip().upper().split() if args.strip() else []
|
|
197
|
+
|
|
198
|
+
period = "1y"
|
|
199
|
+
if parts and parts[-1].lower() in ("1y", "2y", "3y", "6mo", "ytd", "5y"):
|
|
200
|
+
period = parts[-1].lower()
|
|
201
|
+
parts = parts[:-1]
|
|
202
|
+
|
|
203
|
+
symbols = parts if parts else ["AAPL", "MSFT", "TSLA", "SPY", "QQQ"]
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
from data_analysis_tools import calc_correlation_matrix
|
|
207
|
+
except ImportError as e:
|
|
208
|
+
if HAS_RICH:
|
|
209
|
+
console.print(f"[red]data_analysis_tools 未加载: {e}[/red]")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if HAS_RICH:
|
|
213
|
+
with console.status(f"[dim]计算 {', '.join(symbols)} 相关性矩阵...[/dim]", spinner="dots"):
|
|
214
|
+
r = await loop.run_in_executor(None, calc_correlation_matrix,
|
|
215
|
+
{"symbols": symbols, "period": period})
|
|
216
|
+
else:
|
|
217
|
+
r = calc_correlation_matrix({"symbols": symbols, "period": period})
|
|
218
|
+
_render_corr_matrix(r)
|
|
219
|
+
|
|
220
|
+
async def cmd_portfolio_bt(self, args: str):
|
|
221
|
+
"""/ptbt AAPL MSFT GOOG [0.4 0.3 0.3] [2y] [monthly] — 多资产组合回测"""
|
|
222
|
+
import asyncio as _asyncio
|
|
223
|
+
loop = _asyncio.get_event_loop()
|
|
224
|
+
parts = args.strip().split() if args.strip() else []
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
from data_analysis_tools import portfolio_backtest
|
|
228
|
+
except ImportError as e:
|
|
229
|
+
if HAS_RICH:
|
|
230
|
+
console.print(f"[red]data_analysis_tools 未加载: {e}[/red]")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
symbols, weights, period, rebalance = [], [], "2y", "monthly"
|
|
234
|
+
_PERIODS = {"1y", "2y", "3y", "5y", "6mo", "ytd", "max"}
|
|
235
|
+
_REBALANCE = {"monthly", "quarterly", "none"}
|
|
236
|
+
for p in parts:
|
|
237
|
+
pl = p.lower()
|
|
238
|
+
if pl in _PERIODS:
|
|
239
|
+
period = pl
|
|
240
|
+
continue
|
|
241
|
+
if pl in _REBALANCE:
|
|
242
|
+
rebalance = pl
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
f = float(p)
|
|
246
|
+
if f < 2:
|
|
247
|
+
weights.append(f)
|
|
248
|
+
else:
|
|
249
|
+
symbols.append(p.upper())
|
|
250
|
+
except ValueError:
|
|
251
|
+
symbols.append(p.upper())
|
|
252
|
+
|
|
253
|
+
if not symbols:
|
|
254
|
+
symbols = ["AAPL", "MSFT", "GOOGL", "SPY"]
|
|
255
|
+
if HAS_RICH:
|
|
256
|
+
console.print(f"[dim]未指定标的,使用默认: {symbols}[/dim]")
|
|
257
|
+
|
|
258
|
+
p_params = {"symbols": symbols, "period": period, "rebalance": rebalance}
|
|
259
|
+
if weights:
|
|
260
|
+
p_params["weights"] = weights
|
|
261
|
+
|
|
262
|
+
if HAS_RICH:
|
|
263
|
+
with console.status(f"[dim]回测 {', '.join(symbols)} ({period})...[/dim]", spinner="dots"):
|
|
264
|
+
r = await loop.run_in_executor(None, portfolio_backtest, p_params)
|
|
265
|
+
else:
|
|
266
|
+
r = portfolio_backtest(p_params)
|
|
267
|
+
_render_portfolio_bt(r)
|
|
268
|
+
|
|
269
|
+
async def cmd_peer(self, args: str):
|
|
270
|
+
"""/peer <symbol> [peer1 peer2 ...] — 同行估值对比"""
|
|
271
|
+
parts = args.strip().upper().split() if args.strip() else []
|
|
272
|
+
symbol = parts[0] if parts else "AAPL"
|
|
273
|
+
peers = parts[1:] if len(parts) > 1 else []
|
|
274
|
+
|
|
275
|
+
if not _HAS_LOCAL_FINANCE:
|
|
276
|
+
if HAS_RICH:
|
|
277
|
+
console.print("[red]local_finance_tools 未加载[/red]")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
import asyncio as _asyncio
|
|
281
|
+
loop = _asyncio.get_event_loop()
|
|
282
|
+
if HAS_RICH:
|
|
283
|
+
with console.status(f"[dim]获取 {symbol} 同行数据...[/dim]", spinner="dots"):
|
|
284
|
+
from local_finance_tools import _peer_comparison
|
|
285
|
+
r = await loop.run_in_executor(None, _peer_comparison,
|
|
286
|
+
{"symbol": symbol, "peers": peers})
|
|
287
|
+
else:
|
|
288
|
+
from local_finance_tools import _peer_comparison
|
|
289
|
+
r = _peer_comparison({"symbol": symbol, "peers": peers})
|
|
290
|
+
|
|
291
|
+
_render_peer_comparison(r)
|
|
292
|
+
|
|
293
|
+
async def cmd_compare(self, args: str):
|
|
294
|
+
"""多策略横向对比 → /api/v1/backtest/compare-strategies"""
|
|
295
|
+
parts = args.split() if args else ["SPY"]
|
|
296
|
+
symbol = parts[0].upper() if parts else "SPY"
|
|
297
|
+
start = parts[1] if len(parts) > 1 else "2020-01-01"
|
|
298
|
+
end = parts[2] if len(parts) > 2 else __import__("datetime").date.today().isoformat()
|
|
299
|
+
api_url = self.terminal.config.get("api_url", "http://localhost:8000")
|
|
300
|
+
import aiohttp
|
|
301
|
+
|
|
302
|
+
_STRATS = ["momentum", "mean_reversion", "breakout", "turtle", "ma_crossover"]
|
|
303
|
+
|
|
304
|
+
async def _do():
|
|
305
|
+
payload = {"symbol": symbol, "strategies": _STRATS,
|
|
306
|
+
"start_date": start, "end_date": end, "initial_capital": 100000, "commission_rate": 0.0003}
|
|
307
|
+
async with aiohttp.ClientSession() as sess:
|
|
308
|
+
async with sess.post(f"{api_url}/api/v1/backtest/compare-strategies", json=payload, timeout=aiohttp.ClientTimeout(total=90)) as resp:
|
|
309
|
+
if resp.status != 200:
|
|
310
|
+
raise RuntimeError(f"HTTP {resp.status}")
|
|
311
|
+
body = await resp.json()
|
|
312
|
+
return body.get("data", body)
|
|
313
|
+
|
|
314
|
+
def _do_local():
|
|
315
|
+
"""Fallback: run each strategy via the local backtest engine and
|
|
316
|
+
assemble the same shape the backend endpoint returns. Used when the
|
|
317
|
+
backend is down or lacks the compare-strategies endpoint."""
|
|
318
|
+
import asyncio as _aio
|
|
319
|
+
rows = []
|
|
320
|
+
bench_pct = 0.0
|
|
321
|
+
for strat in _STRATS:
|
|
322
|
+
try:
|
|
323
|
+
res = LOCAL_TOOLS["backtest_strategy"][0](
|
|
324
|
+
{"symbol": symbol, "strategy": strat})
|
|
325
|
+
except Exception:
|
|
326
|
+
continue
|
|
327
|
+
if not res.get("success"):
|
|
328
|
+
continue
|
|
329
|
+
d = res.get("data", res)
|
|
330
|
+
ann = float(d.get("annual_return", 0) or 0)
|
|
331
|
+
mdd = float(d.get("max_drawdown", 0) or 0)
|
|
332
|
+
calmar = (ann / abs(mdd)) if mdd else 0.0
|
|
333
|
+
rows.append({
|
|
334
|
+
"name": strat,
|
|
335
|
+
"annualized_return_pct": ann * 100,
|
|
336
|
+
"sharpe_ratio": float(d.get("sharpe_ratio", 0) or 0),
|
|
337
|
+
"max_drawdown_pct": mdd * 100,
|
|
338
|
+
"calmar_ratio": calmar,
|
|
339
|
+
"sortino_ratio": float(d.get("sortino_ratio", 0) or 0),
|
|
340
|
+
"win_rate_pct": float(d.get("win_rate", 0) or 0) * 100,
|
|
341
|
+
"n_trades": int(d.get("total_trades", 0) or 0),
|
|
342
|
+
})
|
|
343
|
+
br = d.get("benchmark_return")
|
|
344
|
+
if br is not None and br == br: # not NaN
|
|
345
|
+
bench_pct = float(br) * 100
|
|
346
|
+
rows.sort(key=lambda r: r["sharpe_ratio"], reverse=True)
|
|
347
|
+
for i, r in enumerate(rows, 1):
|
|
348
|
+
r["rank_by_sharpe"] = i
|
|
349
|
+
return {"strategies": rows, "benchmark": {"annualized_return_pct": bench_pct},
|
|
350
|
+
"provider": "local"}
|
|
351
|
+
|
|
352
|
+
async def _do_with_fallback():
|
|
353
|
+
try:
|
|
354
|
+
return await _do()
|
|
355
|
+
except Exception:
|
|
356
|
+
# Backend unavailable / missing endpoint → local engine
|
|
357
|
+
if HAS_RICH:
|
|
358
|
+
console.print(" [dim]后端不可用,使用本地回测引擎对比…[/dim]")
|
|
359
|
+
return _do_local()
|
|
360
|
+
|
|
361
|
+
if HAS_RICH:
|
|
362
|
+
with console.status(f"[dim]Comparing strategies on {symbol}...[/dim]", spinner="dots"):
|
|
363
|
+
try:
|
|
364
|
+
data = await _do_with_fallback()
|
|
365
|
+
except Exception as e:
|
|
366
|
+
_print_error(str(e), "tool")
|
|
367
|
+
return
|
|
368
|
+
else:
|
|
369
|
+
print(f"Comparing strategies on {symbol}...")
|
|
370
|
+
try:
|
|
371
|
+
data = await _do_with_fallback()
|
|
372
|
+
except Exception as e:
|
|
373
|
+
_print_error(str(e), "tool")
|
|
374
|
+
return
|
|
375
|
+
if not data.get("strategies"):
|
|
376
|
+
_print_error("策略对比无结果", "本地回测引擎未返回数据,检查标的代码是否正确")
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
strategies = data.get("strategies", [])
|
|
380
|
+
bh = data.get("benchmark", {})
|
|
381
|
+
if HAS_RICH:
|
|
382
|
+
from rich.table import Table
|
|
383
|
+
tbl = Table(title=f"[bold]{symbol} Strategy Comparison[/bold] {start} → {end}", show_header=True, header_style="bold")
|
|
384
|
+
for col in ["Rank", "Strategy", "Ann.Ret%", "Sharpe", "MaxDD%", "Calmar", "Sortino", "Win%", "Trades"]:
|
|
385
|
+
tbl.add_column(col, justify="right")
|
|
386
|
+
for s in strategies:
|
|
387
|
+
tbl.add_row(
|
|
388
|
+
str(s.get("rank_by_sharpe", "")),
|
|
389
|
+
s["name"],
|
|
390
|
+
f"{s.get('annualized_return_pct',0):+.1f}%",
|
|
391
|
+
f"{s.get('sharpe_ratio',0):.3f}",
|
|
392
|
+
f"{s.get('max_drawdown_pct',0):.1f}%",
|
|
393
|
+
f"{s.get('calmar_ratio',0):.2f}",
|
|
394
|
+
f"{s.get('sortino_ratio',0):.2f}",
|
|
395
|
+
f"{s.get('win_rate_pct',0):.0f}%",
|
|
396
|
+
str(s.get("n_trades",0)),
|
|
397
|
+
)
|
|
398
|
+
tbl.add_row("—", "[dim]Buy & Hold[/dim]",
|
|
399
|
+
f"{bh.get('annualized_return_pct',0):+.1f}%",
|
|
400
|
+
f"{bh.get('sharpe_ratio',0):.3f}",
|
|
401
|
+
f"{bh.get('max_drawdown_pct',0):.1f}%", "—", "—", "—", "2")
|
|
402
|
+
console.print(tbl)
|
|
403
|
+
else:
|
|
404
|
+
for s in strategies:
|
|
405
|
+
print(f"{s['name']}: Ann={s.get('annualized_return_pct',0):+.1f}% Sharpe={s.get('sharpe_ratio',0):.2f} DD={s.get('max_drawdown_pct',0):.1f}%")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""DiagnosticCommandsMixin — runtime/status/trace/health commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DiagnosticCommandsMixin:
|
|
10
|
+
"""Mixin providing runtime diagnostics commands."""
|
|
11
|
+
|
|
12
|
+
async def cmd_status(self, args: str):
|
|
13
|
+
"""Runtime status panel: engine · tools · model · context · risk"""
|
|
14
|
+
t = self.terminal
|
|
15
|
+
cfg = t.config
|
|
16
|
+
model_id = cfg.get("model", "qwen2.5:7b")
|
|
17
|
+
tool_count = len(ARIA_TOOLS) + len(LOCAL_TOOLS)
|
|
18
|
+
skill_count = len(SKILLS)
|
|
19
|
+
|
|
20
|
+
_lp = t._last_provider or ""
|
|
21
|
+
_badge = next((v.get("badge", "") for v in MODELS.values() if v["id"] == model_id), "")
|
|
22
|
+
if _lp == "ollama":
|
|
23
|
+
runtime = "local (Ollama)"
|
|
24
|
+
elif _lp in ("deepseek", "openai", "anthropic", "groq", "dashscope", "together"):
|
|
25
|
+
runtime = f"cloud ({_lp})"
|
|
26
|
+
elif _badge == "Cloud" or "cloud" in model_id.lower():
|
|
27
|
+
runtime = "cloud"
|
|
28
|
+
else:
|
|
29
|
+
runtime = "local" if getattr(t, "_ollama_alive", False) else "unknown"
|
|
30
|
+
|
|
31
|
+
conv = t.conversation
|
|
32
|
+
est_tok = sum(len(m.get("content", "")) for m in conv) // 3
|
|
33
|
+
max_ctx = get_model_cfg(model_id).get("num_ctx", 16384)
|
|
34
|
+
ctx_pct = min(100, int(est_tok / max_ctx * 100))
|
|
35
|
+
auto_compact = bool(cfg.get("auto_compact_context", True))
|
|
36
|
+
try:
|
|
37
|
+
auto_compact_threshold = float(cfg.get("auto_compact_threshold", 0.78))
|
|
38
|
+
except Exception:
|
|
39
|
+
auto_compact_threshold = 0.78
|
|
40
|
+
auto_compact_runs = int(getattr(t, "_auto_compact_count", 0) or 0)
|
|
41
|
+
provider_summary = None
|
|
42
|
+
try:
|
|
43
|
+
from packages.aria_services.provider_health import GLOBAL_PROVIDER_HEALTH
|
|
44
|
+
provider_summary = GLOBAL_PROVIDER_HEALTH.summary()
|
|
45
|
+
except Exception:
|
|
46
|
+
provider_summary = None
|
|
47
|
+
|
|
48
|
+
mk = next((k for k, v in MODELS.items() if v["id"] == model_id), None)
|
|
49
|
+
model_display = MODELS[mk]["name"] if mk else model_id
|
|
50
|
+
|
|
51
|
+
if HAS_RICH:
|
|
52
|
+
console.print()
|
|
53
|
+
console.print("[bold]Runtime Status[/bold]")
|
|
54
|
+
console.print()
|
|
55
|
+
rows = [
|
|
56
|
+
("runtime", runtime),
|
|
57
|
+
("model", model_display),
|
|
58
|
+
("engine", "quant engine v3.0"),
|
|
59
|
+
("tools", f"{tool_count} available · {skill_count} skills"),
|
|
60
|
+
("risk", "enabled"),
|
|
61
|
+
("context", f"{est_tok:,} / {max_ctx:,} tokens ({ctx_pct}%)"),
|
|
62
|
+
("compact", f"{'on' if auto_compact else 'off'} · {int(auto_compact_threshold * 100)}% · {auto_compact_runs} runs"),
|
|
63
|
+
]
|
|
64
|
+
if provider_summary is not None:
|
|
65
|
+
rows.append(("providers", f"{provider_summary.status} · {provider_summary.detail}"))
|
|
66
|
+
if getattr(t, "_project_session", None):
|
|
67
|
+
rows.append(("project", f"{t._project_session.name} ({t._project_session.stats.get('total_files',0)} files)"))
|
|
68
|
+
if getattr(t, "_file_session", None) and t._file_session.get_active():
|
|
69
|
+
fc = t._file_session.get_active()
|
|
70
|
+
rows.append(("file", f"{fc.filename} ({fc.size_kb:.0f} KB)"))
|
|
71
|
+
rows.append(("banner", cfg.get("banner", "full")))
|
|
72
|
+
rows.append(("workspace", os.getcwd().replace(os.path.expanduser("~"), "~")))
|
|
73
|
+
for k, v in rows:
|
|
74
|
+
console.print(f" [dim]{k:<12}[/dim][cyan]{v}[/cyan]")
|
|
75
|
+
console.print()
|
|
76
|
+
else:
|
|
77
|
+
print("\nRuntime Status")
|
|
78
|
+
print(f" runtime {runtime}")
|
|
79
|
+
print(f" model {model_display}")
|
|
80
|
+
print(f" tools {tool_count}")
|
|
81
|
+
print(f" context {est_tok}/{max_ctx}")
|
|
82
|
+
print(f" compact {'on' if auto_compact else 'off'} threshold={int(auto_compact_threshold * 100)}% runs={auto_compact_runs}")
|
|
83
|
+
if provider_summary is not None:
|
|
84
|
+
print(f" providers {provider_summary.status} {provider_summary.detail}")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
def cmd_trace(self, args: str):
|
|
88
|
+
"""Show runtime trace for recent tool calls."""
|
|
89
|
+
trace = getattr(self.terminal, "runtime_trace", None)
|
|
90
|
+
if trace is None:
|
|
91
|
+
msg = "Runtime trace is unavailable."
|
|
92
|
+
console.print(f"[dim]{msg}[/dim]" if HAS_RICH else msg)
|
|
93
|
+
return
|
|
94
|
+
if "--json" in args.split():
|
|
95
|
+
payload = json.dumps(trace.to_dict(), ensure_ascii=False, indent=2)
|
|
96
|
+
if HAS_RICH:
|
|
97
|
+
console.print(Syntax(payload, "json", theme=_SYNTAX_THEME))
|
|
98
|
+
else:
|
|
99
|
+
print(payload)
|
|
100
|
+
return
|
|
101
|
+
turns = trace.turn_results[-5:]
|
|
102
|
+
calls = trace.tool_calls[-20:]
|
|
103
|
+
if not calls and not turns:
|
|
104
|
+
msg = "No tool calls recorded yet."
|
|
105
|
+
console.print(f"[dim]{msg}[/dim]" if HAS_RICH else msg)
|
|
106
|
+
return
|
|
107
|
+
if HAS_RICH:
|
|
108
|
+
console.print()
|
|
109
|
+
console.print("[bold]Runtime Trace[/bold]")
|
|
110
|
+
console.print()
|
|
111
|
+
if turns:
|
|
112
|
+
console.print(" [dim]Recent turns[/dim]")
|
|
113
|
+
for turn in turns:
|
|
114
|
+
ok = bool(turn.success)
|
|
115
|
+
style = "green" if ok else "red"
|
|
116
|
+
status = turn.status or ("ok" if ok else "err")
|
|
117
|
+
summary = turn.summary or turn.final_text[:120]
|
|
118
|
+
if len(summary) > 120:
|
|
119
|
+
summary = summary[:117] + "..."
|
|
120
|
+
console.print(
|
|
121
|
+
f" [{style}]{status:<8}[/{style}] "
|
|
122
|
+
f"[bold]{turn.provider or '?'}[/bold] "
|
|
123
|
+
f"[dim]{summary}[/dim]"
|
|
124
|
+
)
|
|
125
|
+
console.print()
|
|
126
|
+
for call in calls:
|
|
127
|
+
ok = bool(call.result.get("success"))
|
|
128
|
+
style = "green" if ok else "red"
|
|
129
|
+
console.print(
|
|
130
|
+
f" [{style}]{'ok' if ok else 'err':<3}[/{style}] "
|
|
131
|
+
f"[bold]{call.tool}[/bold] "
|
|
132
|
+
f"[dim]{call.elapsed_ms:.0f} ms[/dim]"
|
|
133
|
+
)
|
|
134
|
+
if not ok and call.result.get("error"):
|
|
135
|
+
console.print(f" [red]{str(call.result.get('error'))[:180]}[/red]")
|
|
136
|
+
console.print()
|
|
137
|
+
else:
|
|
138
|
+
print("\nRuntime Trace")
|
|
139
|
+
for turn in turns:
|
|
140
|
+
ok = "ok" if turn.success else "err"
|
|
141
|
+
summary = turn.summary or turn.final_text[:120]
|
|
142
|
+
if len(summary) > 120:
|
|
143
|
+
summary = summary[:117] + "..."
|
|
144
|
+
print(f" {ok:<3} {turn.provider or '?'} {summary}")
|
|
145
|
+
for call in calls:
|
|
146
|
+
ok = "ok" if call.result.get("success") else "err"
|
|
147
|
+
print(f" {ok:<3} {call.tool} {call.elapsed_ms:.0f} ms")
|
|
148
|
+
print()
|
|
149
|
+
|
|
150
|
+
async def cmd_health(self, args: str):
|
|
151
|
+
import aiohttp
|
|
152
|
+
if HAS_RICH:
|
|
153
|
+
console.print()
|
|
154
|
+
urls = [
|
|
155
|
+
("AWS Backend", self.terminal.api_url, "/health"),
|
|
156
|
+
("Local Server", self.terminal.config.get("local_url", "http://localhost:8001"), "/health"),
|
|
157
|
+
("Ollama", self.terminal.config.get("ollama_url", "http://localhost:11434"), "/api/tags"),
|
|
158
|
+
]
|
|
159
|
+
for label, url, path in urls:
|
|
160
|
+
try:
|
|
161
|
+
async with aiohttp.ClientSession() as session:
|
|
162
|
+
async with session.get(f"{url}{path}", timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
|
163
|
+
data = await resp.json()
|
|
164
|
+
if label == "Ollama":
|
|
165
|
+
models = [m.get("name", "?") for m in data.get("models", [])[:3]]
|
|
166
|
+
detail = ", ".join(models)
|
|
167
|
+
else:
|
|
168
|
+
detail = f"v{data.get('version', '?')}"
|
|
169
|
+
if HAS_RICH:
|
|
170
|
+
console.print(f" [green]●[/green] [dim]{label}[/dim] {detail}")
|
|
171
|
+
else:
|
|
172
|
+
print(f" + {label} {detail}")
|
|
173
|
+
except Exception:
|
|
174
|
+
if HAS_RICH:
|
|
175
|
+
console.print(f" [red]●[/red] [dim]{label}[/dim] offline")
|
|
176
|
+
else:
|
|
177
|
+
print(f" - {label} offline")
|
|
178
|
+
if HAS_RICH:
|
|
179
|
+
console.print()
|