aria-code 4.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
aria_daemon.py ADDED
@@ -0,0 +1,1295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aria_daemon.py — Aria always-on background daemon.
4
+
5
+ Responsibilities:
6
+ 1. Price alert watchdog — checks SQLite alerts every 30 s
7
+ 2. APScheduler cron jobs — morning brief, market scan, custom schedules
8
+ 3. Telegram bot — bidirectional command channel
9
+ 4. Webhook job executor — processes jobs queued by FastAPI /webhook/trigger
10
+ 5. APNs push delivery — fires on alert trigger or scheduled job completion
11
+
12
+ Start manually: python3 aria_daemon.py [--debug]
13
+ Install daemon: python3 aria_daemon.py --install (macOS LaunchAgent)
14
+ Uninstall: python3 aria_daemon.py --uninstall
15
+
16
+ Config via env vars (or ~/.aria/.env):
17
+ TELEGRAM_BOT_TOKEN — Telegram bot token from @BotFather
18
+ TELEGRAM_ALLOWED_IDS — comma-separated chat IDs (e.g. "123456,789012")
19
+ APNS_KEY_ID — Apple Developer key ID (10-char string)
20
+ APNS_TEAM_ID — Apple Developer team ID
21
+ APNS_BUNDLE_ID — App bundle ID (default: com.arthera.app)
22
+ APNS_SANDBOX — "true" for sandbox / TestFlight (default: true)
23
+ APNS_AUTH_KEY_P8 — .p8 key content, or place file at ~/.aria/apns.p8
24
+ WEBHOOK_TOKEN — Static token for /api/v1/webhook/trigger
25
+ ARIA_WEBHOOK_HOST — Daemon webhook host (default: 127.0.0.1)
26
+ ARIA_WEBHOOK_PORT — Daemon webhook port (default: 8765)
27
+ ARIA_API_BASE — FastAPI backend URL (default: http://localhost:8000)
28
+ ARIA_CODE_DIR — Path to aria-code directory
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import logging
34
+ import os
35
+ import signal
36
+ import sqlite3
37
+ import sys
38
+ import time
39
+ from datetime import datetime
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ # ── Logging ───────────────────────────────────────────────────────────────────
44
+
45
+ _LOG_DIR = Path.home() / ".aria" / "logs"
46
+ _LOG_DIR.mkdir(parents=True, exist_ok=True)
47
+
48
+ logging.basicConfig(
49
+ level=logging.DEBUG if "--debug" in sys.argv else logging.INFO,
50
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
51
+ handlers=[
52
+ logging.StreamHandler(sys.stdout),
53
+ logging.FileHandler(_LOG_DIR / "daemon.log", encoding="utf-8"),
54
+ ],
55
+ )
56
+ logger = logging.getLogger("aria.daemon")
57
+
58
+ # ── Paths & config ────────────────────────────────────────────────────────────
59
+
60
+ _ARIA_DIR = Path.home() / ".aria"
61
+ _DB_PATH = _ARIA_DIR / "daemon.db"
62
+ _PID_FILE = _ARIA_DIR / "daemon.pid"
63
+ _ENV_FILE = _ARIA_DIR / ".env"
64
+
65
+ _ARIA_CODE_DIR = Path(os.environ.get("ARIA_CODE_DIR", Path(__file__).parent))
66
+ if str(_ARIA_CODE_DIR) not in sys.path:
67
+ sys.path.insert(0, str(_ARIA_CODE_DIR))
68
+
69
+
70
+ def _load_env() -> None:
71
+ """Load ~/.aria/.env into os.environ if it exists."""
72
+ if _ENV_FILE.exists():
73
+ for line in _ENV_FILE.read_text().splitlines():
74
+ line = line.strip()
75
+ if line and not line.startswith("#") and "=" in line:
76
+ k, _, v = line.partition("=")
77
+ os.environ.setdefault(k.strip(), v.strip())
78
+
79
+
80
+ _load_env()
81
+
82
+
83
+ # ── DB bootstrap ───────────────────────────────────────────────────────────────
84
+
85
+ def _init_db() -> None:
86
+ _ARIA_DIR.mkdir(parents=True, exist_ok=True)
87
+ with sqlite3.connect(_DB_PATH) as conn:
88
+ conn.executescript("""
89
+ CREATE TABLE IF NOT EXISTS device_tokens (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ token TEXT UNIQUE NOT NULL,
92
+ platform TEXT DEFAULT 'ios',
93
+ user_id TEXT,
94
+ bundle_id TEXT,
95
+ created_at TEXT DEFAULT (datetime('now')),
96
+ updated_at TEXT DEFAULT (datetime('now'))
97
+ );
98
+ CREATE TABLE IF NOT EXISTS alerts (
99
+ id TEXT PRIMARY KEY,
100
+ symbol TEXT NOT NULL,
101
+ condition TEXT NOT NULL,
102
+ value REAL NOT NULL,
103
+ message TEXT,
104
+ notify_push INTEGER DEFAULT 1,
105
+ notify_telegram INTEGER DEFAULT 1,
106
+ notify_email TEXT,
107
+ once INTEGER DEFAULT 1,
108
+ active INTEGER DEFAULT 1,
109
+ trigger_count INTEGER DEFAULT 0,
110
+ created_at TEXT DEFAULT (datetime('now')),
111
+ triggered_at TEXT
112
+ );
113
+ CREATE TABLE IF NOT EXISTS schedules (
114
+ id TEXT PRIMARY KEY,
115
+ name TEXT,
116
+ cron_expr TEXT NOT NULL,
117
+ command TEXT NOT NULL,
118
+ symbols TEXT DEFAULT '[]',
119
+ channels TEXT DEFAULT '["ios","telegram"]',
120
+ language TEXT DEFAULT 'zh',
121
+ user_id TEXT,
122
+ enabled INTEGER DEFAULT 1,
123
+ created_at TEXT DEFAULT (datetime('now')),
124
+ last_run TEXT,
125
+ next_run TEXT
126
+ );
127
+ CREATE TABLE IF NOT EXISTS webhook_jobs (
128
+ id TEXT PRIMARY KEY,
129
+ command TEXT NOT NULL,
130
+ payload TEXT DEFAULT '{}',
131
+ source TEXT DEFAULT 'external',
132
+ status TEXT DEFAULT 'pending',
133
+ result TEXT,
134
+ created_at TEXT DEFAULT (datetime('now')),
135
+ started_at TEXT,
136
+ done_at TEXT
137
+ );
138
+ CREATE TABLE IF NOT EXISTS push_log (
139
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
140
+ alert_id TEXT,
141
+ device_token TEXT,
142
+ title TEXT,
143
+ body TEXT,
144
+ status TEXT DEFAULT 'pending',
145
+ created_at TEXT DEFAULT (datetime('now'))
146
+ );
147
+ """)
148
+ conn.commit()
149
+
150
+
151
+ _init_db()
152
+
153
+
154
+ # ── Price fetch (lightweight, no VPN proxy) ────────────────────────────────────
155
+
156
+ async def _fetch_price(symbol: str) -> tuple[Optional[float], Optional[float]]:
157
+ """Fast price lookup. Returns (current_price, prev_close) for alert evaluation."""
158
+ import math
159
+ try:
160
+ import yfinance as yf
161
+ if symbol.isdigit() and len(symbol) == 6:
162
+ yfn = symbol + (".SS" if symbol.startswith(("6", "5")) else ".SZ")
163
+ else:
164
+ yfn = symbol
165
+ ticker = yf.Ticker(yfn)
166
+ info = ticker.fast_info
167
+ raw_price = getattr(info, "last_price", None) or getattr(info, "previous_close", None)
168
+ raw_prev = getattr(info, "previous_close", None)
169
+ # yfinance can return float('nan') — treat as None
170
+ price = float(raw_price) if raw_price is not None and not math.isnan(float(raw_price)) else None
171
+ prev_close = float(raw_prev) if raw_prev is not None and not math.isnan(float(raw_prev)) else None
172
+ return (price, prev_close)
173
+ except Exception as exc:
174
+ logger.debug("_fetch_price %s: %s", symbol, exc)
175
+ return (None, None)
176
+
177
+
178
+ # ── Push notifications ────────────────────────────────────────────────────────
179
+
180
+ async def _push_alert(title: str, body: str, extra: Optional[dict] = None, alert_id: Optional[str] = None) -> None:
181
+ """Try to push via APNs; log any failure but never raise."""
182
+ try:
183
+ sys.path.insert(0, str(Path(__file__).parent.parent / "Arthera" / "apps" / "api" / "src"))
184
+ from services.apns_service import push_to_all
185
+ sent = await push_to_all(title, body, extra, alert_id)
186
+ logger.info("APNs push: %d device(s) notified for alert_id=%s", sent, alert_id)
187
+ except ImportError:
188
+ logger.debug("apns_service not reachable — skipping push")
189
+
190
+
191
+ # ── Telegram push (one-shot, no polling) ─────────────────────────────────────
192
+
193
+ async def _telegram_push(text: str) -> None:
194
+ token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
195
+ allowed_raw = os.environ.get("TELEGRAM_ALLOWED_IDS", "")
196
+ if not token or not allowed_raw:
197
+ return
198
+ chat_ids = [int(x.strip()) for x in allowed_raw.split(",") if x.strip().isdigit()]
199
+ try:
200
+ import httpx
201
+ async with httpx.AsyncClient(timeout=10) as client:
202
+ for cid in chat_ids:
203
+ await client.post(
204
+ f"https://api.telegram.org/bot{token}/sendMessage",
205
+ json={"chat_id": cid, "text": text, "parse_mode": "Markdown"},
206
+ )
207
+ except Exception as exc:
208
+ logger.warning("_telegram_push failed: %s", exc)
209
+
210
+
211
+ # ── Feishu push (webhook card) ────────────────────────────────────────────────
212
+
213
+ async def _feishu_push(title: str, body: str) -> None:
214
+ """POST an interactive card to a Feishu group webhook (FEISHU_WEBHOOK_URL)."""
215
+ url = os.environ.get("FEISHU_WEBHOOK_URL", "")
216
+ if not url:
217
+ return
218
+ color = "red" if any(w in title for w in ("预警", "Alert", "熔断", "ERROR")) else \
219
+ "green" if any(w in title for w in ("晨报", "完成", "Brief")) else "blue"
220
+ card = {
221
+ "msg_type": "interactive",
222
+ "card": {
223
+ "header": {
224
+ "title": {"tag": "plain_text", "content": title},
225
+ "template": color,
226
+ },
227
+ "elements": [
228
+ {"tag": "div", "text": {"tag": "lark_md", "content": body[:2000]}},
229
+ {"tag": "hr"},
230
+ {"tag": "note", "elements": [
231
+ {"tag": "plain_text",
232
+ "content": "Aria Daemon · " + __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")},
233
+ ]},
234
+ ],
235
+ },
236
+ }
237
+ try:
238
+ import httpx
239
+ async with httpx.AsyncClient(timeout=10) as client:
240
+ resp = await client.post(url, json=card)
241
+ if resp.status_code >= 400:
242
+ logger.warning("_feishu_push HTTP %s: %s", resp.status_code, resp.text[:120])
243
+ except Exception as exc:
244
+ logger.warning("_feishu_push failed: %s", exc)
245
+
246
+
247
+ # ── Alert watchdog ────────────────────────────────────────────────────────────
248
+
249
+ async def _alert_watchdog() -> None:
250
+ """Check all active alerts against live prices every 30 seconds."""
251
+ logger.info("Alert watchdog started")
252
+ while True:
253
+ try:
254
+ with sqlite3.connect(_DB_PATH) as conn:
255
+ conn.row_factory = sqlite3.Row
256
+ rows = conn.execute(
257
+ "SELECT * FROM alerts WHERE active=1"
258
+ ).fetchall()
259
+ alerts = [dict(r) for r in rows]
260
+
261
+ # Group by symbol to minimise API calls
262
+ by_symbol: dict[str, list[dict]] = {}
263
+ for a in alerts:
264
+ by_symbol.setdefault(a["symbol"], []).append(a)
265
+
266
+ for symbol, sym_alerts in by_symbol.items():
267
+ price, prev_close = await _fetch_price(symbol)
268
+ if price is None:
269
+ continue
270
+ for alert in sym_alerts:
271
+ await _check_alert(alert, price, prev_close)
272
+
273
+ except Exception as exc:
274
+ logger.error("Alert watchdog error: %s", exc)
275
+
276
+ await asyncio.sleep(30)
277
+
278
+
279
+ async def _check_alert(alert: dict, price: float, prev_close: Optional[float] = None) -> None:
280
+ cond = alert["condition"]
281
+ val = float(alert["value"])
282
+
283
+ # pct_change 条件: prev_close=None 时跳过而非永远不触发
284
+ if cond in ("pct_change_above", "pct_change_below") and (not prev_close or prev_close <= 0):
285
+ logger.debug("Alert %s: prev_close unavailable, skipping pct_change check", alert["id"])
286
+ return
287
+
288
+ pct_chg = ((price - prev_close) / prev_close * 100) if prev_close and prev_close > 0 else None
289
+ fired = (
290
+ (cond == "price_above" and price > val) or
291
+ (cond == "price_below" and price < val) or
292
+ (cond == "pct_change_above" and pct_chg is not None and pct_chg > val) or
293
+ (cond == "pct_change_below" and pct_chg is not None and pct_chg < val)
294
+ )
295
+ if not fired:
296
+ return
297
+
298
+ symbol = alert["symbol"]
299
+ pct_str = f" ({pct_chg:+.2f}%)" if pct_chg is not None else ""
300
+ message = alert.get("message") or f"{symbol} {cond.replace('_', ' ')} {val}"
301
+ title = "🔔 ARIA 价格预警"
302
+ body = f"{symbol} ¥{price:.2f}{pct_str} — {message}"
303
+
304
+ logger.info("Alert triggered: %s | %s @ %.4f", alert["id"], symbol, price)
305
+
306
+ # Update DB
307
+ with sqlite3.connect(_DB_PATH) as conn:
308
+ conn.execute(
309
+ "UPDATE alerts SET trigger_count=trigger_count+1, triggered_at=datetime('now')"
310
+ + (", active=0" if alert.get("once", 1) else "")
311
+ + " WHERE id=?",
312
+ (alert["id"],),
313
+ )
314
+ conn.commit()
315
+
316
+ extra = {"symbol": symbol, "price": price, "signal": "ALERT"}
317
+
318
+ if alert.get("notify_push", 1):
319
+ await _push_alert(title, body, extra, alert["id"])
320
+
321
+ if alert.get("notify_telegram", 1):
322
+ await _telegram_push(f"*{title}*\n{body}")
323
+ await _feishu_push(title, body)
324
+ _system_notify(title, body)
325
+
326
+ # 触发后自动运行轻量分析(technical + risk),结果推送给用户
327
+ asyncio.create_task(_auto_analyze_alert(symbol, price, pct_str))
328
+
329
+
330
+ async def _auto_analyze_alert(symbol: str, price: float, pct_str: str) -> None:
331
+ """Run a lightweight technical+risk analysis after an alert fires and push the result."""
332
+ try:
333
+ sys.path.insert(0, str(_ARIA_CODE_DIR))
334
+ from agents.team import AgentTeam
335
+ team = AgentTeam(agent_names=["technical", "risk"])
336
+ result = await asyncio.wait_for(team.run(symbol), timeout=45.0)
337
+ signal = result.signal or "N/A"
338
+ conf = f"{result.confidence:.0%}" if result.confidence else "?"
339
+ points = "\n".join(f" • {p}" for p in (result.key_points or [])[:3])
340
+ body = (
341
+ f"🤖 *{symbol}* 预警后快速分析\n"
342
+ f"现价 {price:.4f}{pct_str}\n\n"
343
+ f"Signal: *{signal}* 置信度: {conf}\n"
344
+ f"{points}"
345
+ )
346
+ await _telegram_push(body)
347
+ await _feishu_push(f"🤖 {symbol} 预警分析", body.replace("*", "**"))
348
+ except asyncio.TimeoutError:
349
+ logger.warning("_auto_analyze_alert %s: timeout", symbol)
350
+ except Exception as exc:
351
+ logger.debug("_auto_analyze_alert %s: %s", symbol, exc)
352
+
353
+
354
+ # ── Telegram command handler ─────────────────────────────────────────────────
355
+
356
+ async def _telegram_command(cmd: str, args: str, chat_id: int) -> str:
357
+ """Route Telegram bot commands to Aria functions."""
358
+ args = args.strip()
359
+ logger.info("Telegram cmd=/%s args=%s chat=%d", cmd, args[:40], chat_id)
360
+
361
+ if cmd == "help":
362
+ return (
363
+ "*Aria 命令列表*\n\n"
364
+ "`/price SYMBOL` — 实时报价\n"
365
+ "`/report SYMBOL` — 深度分析研报\n"
366
+ "`/brief` — 今日晨报\n"
367
+ "`/screen` — 热门 A 股筛选\n"
368
+ "`/alert SYMBOL cond value` — 添加价格预警\n"
369
+ " 条件: price\\_above / price\\_below / pct\\_change\\_above\n"
370
+ "`/alerts` — 查看预警列表\n"
371
+ "`/status` — Daemon 运行状态\n"
372
+ "\n直接发文字也可对话 Aria。"
373
+ )
374
+
375
+ if cmd in ("price", "p"):
376
+ symbol = args.upper() or "SPY"
377
+ price, _ = await _fetch_price(symbol)
378
+ if price:
379
+ return f"*{symbol}* 当前价格: `¥{price:.4f}`" if len(symbol) == 6 and symbol.isdigit() else f"*{symbol}* ${price:.4f}"
380
+ return f"⚠️ 无法获取 {symbol} 价格(市场可能已关闭)"
381
+
382
+ if cmd == "report":
383
+ symbol = args.upper()
384
+ if not symbol:
385
+ return "用法: `/report AAPL` 或 `/report 600519`"
386
+ return await _run_report(symbol)
387
+
388
+ if cmd in ("brief", "morning", "briefing"):
389
+ return await _run_morning_brief()
390
+
391
+ if cmd == "screen":
392
+ return await _run_screener()
393
+
394
+ if cmd == "alert":
395
+ return await _handle_alert_add(args, chat_id)
396
+
397
+ if cmd == "alerts":
398
+ return _list_alerts()
399
+
400
+ if cmd == "status":
401
+ return _daemon_status()
402
+
403
+ if cmd == "chat":
404
+ # Natural language fallback
405
+ return await _run_chat(args)
406
+
407
+ return f"未知命令 `/{cmd}`。发送 `/help` 查看命令列表。"
408
+
409
+
410
+ async def _run_report(symbol: str) -> str:
411
+ """Generate a quick text summary report."""
412
+ try:
413
+ price, _pc = await _fetch_price(symbol)
414
+ price_str = f"¥{price:.2f}" if price and len(symbol) == 6 and symbol.isdigit() else (f"${price:.2f}" if price else "N/A")
415
+
416
+ # Try to import aria-code market data for indicators
417
+ try:
418
+ import importlib.util
419
+ spec = importlib.util.spec_from_file_location(
420
+ "market_data_client",
421
+ _ARIA_CODE_DIR / "market_data_client.py",
422
+ )
423
+ mdc = importlib.util.module_from_spec(spec)
424
+ spec.loader.exec_module(mdc)
425
+ client = mdc.MarketDataClient()
426
+ quote = await asyncio.wait_for(client.quote(symbol), timeout=8.0)
427
+ name = quote.get("name", symbol)
428
+ chg = quote.get("change_pct", 0)
429
+ vol = quote.get("volume", 0)
430
+ chg_str = f"{chg:+.2f}%" if chg else ""
431
+ except Exception:
432
+ name, chg_str, vol = symbol, "", 0
433
+
434
+ lines = [
435
+ f"*{name}* `{symbol}` 快速分析",
436
+ f"",
437
+ f"💰 价格: `{price_str}` {chg_str}",
438
+ f"📊 成交量: `{vol:,}`" if vol else "",
439
+ f"",
440
+ f"⚠️ 深度研报请在 Terminal 使用 `/report {symbol}` 命令。",
441
+ f"此处为轻量版摘要。",
442
+ ]
443
+ return "\n".join(l for l in lines if l is not None)
444
+
445
+ except Exception as exc:
446
+ logger.error("_run_report %s: %s", symbol, exc)
447
+ return f"⚠️ 分析 {symbol} 时出错: {exc}"
448
+
449
+
450
+ async def _run_tradingview_alert(payload: dict) -> str:
451
+ """Handle a TradingView alert webhook job."""
452
+ try:
453
+ from apps.cli.tradingview_bridge import build_tradingview_order_preview, parse_tradingview_alert
454
+ alert = parse_tradingview_alert(payload)
455
+ except Exception as exc:
456
+ raise ValueError(f"TradingView alert 解析失败: {exc}") from exc
457
+
458
+ symbol = str(alert.get("symbol") or "").upper()
459
+ if not symbol:
460
+ raise ValueError("TradingView alert 缺少 symbol/ticker")
461
+
462
+ action = str(alert.get("action") or "ALERT").upper()
463
+ price = alert.get("price")
464
+ price_line = f"Price: `{price}`" if price not in (None, "") else "Price: `N/A`"
465
+ msg = str(alert.get("message") or "").strip()
466
+ header = [
467
+ f"*TradingView Alert* `{symbol}`",
468
+ f"Action: *{action}*",
469
+ price_line,
470
+ ]
471
+ if msg:
472
+ header.append(f"Message: {msg[:300]}")
473
+
474
+ preview_lines: list[str] = []
475
+ try:
476
+ preview_result = build_tradingview_order_preview(payload)
477
+ if preview_result.get("trade_preview_created"):
478
+ blockers = preview_result.get("execution_blockers") or []
479
+ preview_lines.extend([
480
+ "",
481
+ "*Trade Preview*",
482
+ f"preview_id: `{preview_result.get('preview_id')}`",
483
+ f"Mode: `{preview_result.get('mode')}` Broker: `{preview_result.get('broker_label')}`",
484
+ f"Can execute after confirmation: `{bool(preview_result.get('can_execute'))}`",
485
+ f"Confirm: `{preview_result.get('confirm_command')}`",
486
+ ])
487
+ if blockers:
488
+ preview_lines.append("Blockers: " + "; ".join(str(item) for item in blockers[:4]))
489
+ preview_lines.append("Webhook 只生成预览,不会自动执行实盘/仿盘订单。")
490
+ elif preview_result.get("success"):
491
+ reason = preview_result.get("reason", "no_trade_preview")
492
+ preview_lines.extend(["", "*Trade Preview*", f"未生成订单预览: `{reason}`"])
493
+ if preview_result.get("hint"):
494
+ preview_lines.append(str(preview_result["hint"]))
495
+ else:
496
+ preview_lines.extend(["", "*Trade Preview*", f"生成失败: `{preview_result.get('error')}`"])
497
+ except Exception as exc:
498
+ logger.warning("TradingView trade preview failed: %s", exc)
499
+ preview_lines.extend(["", "*Trade Preview*", f"生成失败: `{exc}`"])
500
+
501
+ analysis = await _run_report(symbol)
502
+ return "\n".join(header + preview_lines) + "\n\n" + analysis
503
+
504
+
505
+ async def _run_morning_brief() -> str:
506
+ """Enriched morning market brief — A-share + US + crypto + FX."""
507
+ try:
508
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
509
+ lines = [f"*🌅 Aria 晨报 {now_str}*\n"]
510
+
511
+ # ── A-share indices ────────────────────────────────────────────────
512
+ cn_indices = [
513
+ ("上证指数", "000001.SS"), ("深证成指", "399001.SZ"),
514
+ ("创业板", "399006.SZ"), ("沪深300", "000300.SS"),
515
+ ]
516
+ lines.append("*🇨🇳 A股指数*")
517
+ for name, sym in cn_indices:
518
+ p, pc = await _fetch_price(sym)
519
+ if p:
520
+ arrow = "📈" if (pc or 0) >= 0 else "📉"
521
+ pct_str = f" {pc:+.2f}%" if pc is not None else ""
522
+ lines.append(f" {arrow} {name}: `¥{p:.2f}`{pct_str}")
523
+ else:
524
+ lines.append(f" — {name}: N/A")
525
+
526
+ # ── US markets ────────────────────────────────────────────────────
527
+ us_indices = [
528
+ ("S&P 500", "^GSPC"), ("纳斯达克", "^IXIC"),
529
+ ("道琼斯", "^DJI"), ("VIX恐慌", "^VIX"),
530
+ ]
531
+ lines.append("\n*🇺🇸 美股指数*")
532
+ for name, sym in us_indices:
533
+ p, pc = await _fetch_price(sym)
534
+ if p:
535
+ arrow = "📈" if (pc or 0) >= 0 else "📉"
536
+ pct_str = f" {pc:+.2f}%" if pc is not None else ""
537
+ lines.append(f" {arrow} {name}: `{p:.2f}`{pct_str}")
538
+ else:
539
+ lines.append(f" — {name}: N/A")
540
+
541
+ # ── Crypto ────────────────────────────────────────────────────────
542
+ crypto_pairs = [("BTC", "BTC-USD"), ("ETH", "ETH-USD"), ("黄金", "GC=F")]
543
+ lines.append("\n*₿ 加密 & 大宗*")
544
+ for name, sym in crypto_pairs:
545
+ p, pc = await _fetch_price(sym)
546
+ if p:
547
+ pct_str = f" {pc:+.2f}%" if pc is not None else ""
548
+ lines.append(f" {name}: `${p:,.2f}`{pct_str}")
549
+ else:
550
+ lines.append(f" {name}: N/A")
551
+
552
+ # ── FX ───────────────────────────────────────────────────────────
553
+ fx_pairs = [("USD/CNY", "CNY=X"), ("USD/JPY", "JPY=X")]
554
+ lines.append("\n*💱 汇率*")
555
+ for name, sym in fx_pairs:
556
+ p, _ = await _fetch_price(sym)
557
+ lines.append(f" {name}: `{p:.4f}`" if p else f" {name}: N/A")
558
+
559
+ # ── Portfolio P&L summary ─────────────────────────────────────────
560
+ pnl = await _get_portfolio_daily_pnl()
561
+ if pnl:
562
+ lines.append(f"\n*💼 持仓日内 P&L*")
563
+ lines.append(pnl)
564
+
565
+ lines.append("\n_数据来自 yfinance,仅供参考_")
566
+ return "\n".join(lines)
567
+ except Exception as exc:
568
+ logger.warning("morning brief error: %s", exc)
569
+ return f"⚠️ 晨报生成失败: {exc}"
570
+
571
+
572
+ async def _run_evening_brief() -> str:
573
+ """Post-market A-share evening summary."""
574
+ try:
575
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
576
+ lines = [f"*🌆 Aria 收盘简报 {now_str}*\n"]
577
+
578
+ cn_indices = [
579
+ ("上证指数", "000001.SS"), ("深证成指", "399001.SZ"),
580
+ ("创业板", "399006.SZ"), ("沪深300", "000300.SS"),
581
+ ]
582
+ lines.append("*今日收盘*")
583
+ for name, sym in cn_indices:
584
+ p, pc = await _fetch_price(sym)
585
+ if p:
586
+ arrow = "📈" if (pc or 0) >= 0 else "📉"
587
+ pct_str = f" {pc:+.2f}%" if pc is not None else ""
588
+ lines.append(f" {arrow} {name}: `¥{p:.2f}`{pct_str}")
589
+
590
+ pnl = await _get_portfolio_daily_pnl()
591
+ if pnl:
592
+ lines.append(f"\n*💼 今日持仓损益*\n{pnl}")
593
+
594
+ lines.append("\n_数据来自 yfinance · 请以实盘数据为准_")
595
+ return "\n".join(lines)
596
+ except Exception as exc:
597
+ return f"⚠️ 收盘简报失败: {exc}"
598
+
599
+
600
+ async def _run_weekly_recap() -> str:
601
+ """Friday end-of-week performance summary."""
602
+ try:
603
+ now_str = datetime.now().strftime("%Y 第%W周")
604
+ lines = [f"*📅 Aria 周报 {now_str}*\n"]
605
+
606
+ us_etfs = [("S&P500", "SPY"), ("纳指", "QQQ"), ("罗素", "IWM"), ("黄金", "GLD")]
607
+ lines.append("*本周美股 ETF 表现*")
608
+ for name, sym in us_etfs:
609
+ try:
610
+ import yfinance as _yf
611
+ df = _yf.download(sym, period="5d", progress=False, auto_adjust=True)
612
+ if not df.empty and len(df) >= 2:
613
+ if hasattr(df.columns, 'levels'):
614
+ df.columns = df.columns.droplevel(1)
615
+ w_ret = (df["Close"].iloc[-1] / df["Close"].iloc[0] - 1) * 100
616
+ arrow = "📈" if w_ret >= 0 else "📉"
617
+ lines.append(f" {arrow} {name}: `{w_ret:+.2f}%`")
618
+ except Exception:
619
+ lines.append(f" — {name}: N/A")
620
+
621
+ cn_indices = [("上证", "000001.SS"), ("沪深300", "000300.SS"), ("创业板", "399006.SZ")]
622
+ lines.append("\n*本周A股指数*")
623
+ for name, sym in cn_indices:
624
+ try:
625
+ import yfinance as _yf
626
+ df = _yf.download(sym, period="5d", progress=False, auto_adjust=True)
627
+ if not df.empty and len(df) >= 2:
628
+ if hasattr(df.columns, 'levels'):
629
+ df.columns = df.columns.droplevel(1)
630
+ w_ret = (df["Close"].iloc[-1] / df["Close"].iloc[0] - 1) * 100
631
+ arrow = "📈" if w_ret >= 0 else "📉"
632
+ lines.append(f" {arrow} {name}: `{w_ret:+.2f}%`")
633
+ except Exception:
634
+ lines.append(f" — {name}: N/A")
635
+
636
+ lines.append("\n_数据来自 yfinance,仅供参考_")
637
+ return "\n".join(lines)
638
+ except Exception as exc:
639
+ return f"⚠️ 周报生成失败: {exc}"
640
+
641
+
642
+ async def _get_portfolio_daily_pnl() -> str:
643
+ """Read portfolio positions from DB and compute daily P&L."""
644
+ try:
645
+ portfolio_db = _ARIA_DIR / "portfolio.db"
646
+ if not portfolio_db.exists():
647
+ return ""
648
+ with sqlite3.connect(portfolio_db) as conn:
649
+ conn.row_factory = sqlite3.Row
650
+ rows = conn.execute(
651
+ "SELECT symbol, quantity, avg_cost FROM positions WHERE quantity > 0 LIMIT 10"
652
+ ).fetchall()
653
+ if not rows:
654
+ return ""
655
+ total_pnl = 0.0
656
+ position_lines = []
657
+ for r in rows:
658
+ sym = r["symbol"]
659
+ qty = r["quantity"]
660
+ cost = r["avg_cost"]
661
+ price, pct = await _fetch_price(sym)
662
+ if price and cost and qty:
663
+ day_pnl = (price - cost) * qty
664
+ total_pnl += day_pnl
665
+ arrow = "📈" if day_pnl >= 0 else "📉"
666
+ position_lines.append(
667
+ f" {arrow} {sym}: ¥{price:.2f} 日盈亏 `{day_pnl:+.2f}`"
668
+ )
669
+ if not position_lines:
670
+ return ""
671
+ result = "\n".join(position_lines)
672
+ result += f"\n 合计: `{total_pnl:+.2f}`"
673
+ return result
674
+ except Exception as exc:
675
+ logger.debug("portfolio pnl error: %s", exc)
676
+ return ""
677
+
678
+
679
+ async def _portfolio_loss_watchdog() -> None:
680
+ """Check portfolio positions every 5 min; push alert if any position drops > threshold."""
681
+ LOSS_THRESHOLD_PCT = float(os.environ.get("ARIA_LOSS_ALERT_PCT", "5.0"))
682
+ logger.info("Portfolio loss watchdog started (threshold: %.1f%%)", LOSS_THRESHOLD_PCT)
683
+ while True:
684
+ try:
685
+ portfolio_db = _ARIA_DIR / "portfolio.db"
686
+ if portfolio_db.exists():
687
+ with sqlite3.connect(portfolio_db) as conn:
688
+ conn.row_factory = sqlite3.Row
689
+ rows = conn.execute(
690
+ "SELECT symbol, quantity, avg_cost FROM positions WHERE quantity > 0"
691
+ ).fetchall()
692
+ for r in rows:
693
+ sym = r["symbol"]
694
+ qty = float(r["quantity"])
695
+ cost = float(r["avg_cost"] or 0)
696
+ if qty <= 0 or cost <= 0:
697
+ continue
698
+ price, pct = await _fetch_price(sym)
699
+ if price and pct is not None and pct <= -LOSS_THRESHOLD_PCT:
700
+ day_loss = (price - cost) * qty
701
+ title = f"⚠️ {sym} 跌幅预警"
702
+ body = (
703
+ f"{sym} 当前价 ¥{price:.2f},"
704
+ f"日跌幅 {pct:.2f}%,"
705
+ f"持仓损益 {day_loss:+.2f} 元"
706
+ )
707
+ logger.info("Portfolio loss alert: %s %.2f%%", sym, pct)
708
+ await _push_alert(title, body)
709
+ await _telegram_push(f"*{title}*\n{body}")
710
+ await _feishu_push(title, body)
711
+ except Exception as exc:
712
+ logger.debug("portfolio watchdog error: %s", exc)
713
+ await asyncio.sleep(300)
714
+
715
+
716
+ def _system_notify(title: str, body: str) -> None:
717
+ """Fire a native OS desktop notification (non-blocking, best-effort)."""
718
+ try:
719
+ import platform as _pl
720
+ if _pl.system() == "Darwin":
721
+ import subprocess as _sp
722
+ safe_title = title.replace('"', '\\"').replace("'", "\\'")
723
+ safe_body = body.replace('"', '\\"').replace("'", "\\'")
724
+ _sp.Popen(
725
+ ["osascript", "-e",
726
+ f'display notification "{safe_body}" with title "{safe_title}" subtitle "Aria Code"'],
727
+ stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
728
+ )
729
+ elif _pl.system() == "Windows":
730
+ try:
731
+ from win10toast import ToastNotifier
732
+ ToastNotifier().show_toast(title, body, duration=6, threaded=True)
733
+ except ImportError:
734
+ pass
735
+ except Exception:
736
+ pass
737
+
738
+
739
+ async def _run_screener() -> str:
740
+ """Quick hot-stock screener using market_data_client if available."""
741
+ try:
742
+ spec = __import__("importlib.util").util.spec_from_file_location(
743
+ "market_data_client", _ARIA_CODE_DIR / "market_data_client.py"
744
+ )
745
+ mdc = __import__("importlib.util").util.module_from_spec(spec)
746
+ spec.loader.exec_module(mdc)
747
+ client = mdc.MarketDataClient()
748
+ result = await asyncio.wait_for(client.hot_ashare(limit=8), timeout=10.0)
749
+ stocks = result if isinstance(result, list) else []
750
+ if not stocks:
751
+ return "⚠️ 暂时无法获取行情数据(市场未开市或数据源异常)"
752
+ lines = ["*🔥 A股热门*\n"]
753
+ for s in stocks[:8]:
754
+ sym = s.get("symbol", "")
755
+ name = s.get("name", sym)
756
+ p = s.get("price", 0)
757
+ chg = s.get("change_pct", 0)
758
+ lines.append(f" `{sym}` {name} ¥{p:.2f} {chg:+.2f}%")
759
+ return "\n".join(lines)
760
+ except Exception as exc:
761
+ return f"⚠️ 筛选器出错: {exc}"
762
+
763
+
764
+ async def _handle_alert_add(args: str, chat_id: int) -> str:
765
+ """Parse and store an alert from Telegram. Format: SYMBOL cond value"""
766
+ parts = args.split()
767
+ if len(parts) < 3:
768
+ return (
769
+ "用法: `/alert SYMBOL condition value`\n"
770
+ "示例: `/alert 600362 price_below 39.5`\n"
771
+ "条件: `price_above` / `price_below`"
772
+ )
773
+ symbol, cond, val_str = parts[0].upper(), parts[1].lower(), parts[2]
774
+ valid_conds = {"price_above", "price_below", "pct_change_above", "pct_change_below"}
775
+ if cond not in valid_conds:
776
+ return f"⚠️ 无效条件 `{cond}`。可用: {', '.join(valid_conds)}"
777
+ try:
778
+ val = float(val_str)
779
+ except ValueError:
780
+ return f"⚠️ 无效数值: `{val_str}`"
781
+
782
+ import uuid
783
+ alert_id = str(uuid.uuid4())[:8]
784
+ with sqlite3.connect(_DB_PATH) as conn:
785
+ conn.execute(
786
+ "INSERT INTO alerts (id, symbol, condition, value, message, notify_push, notify_telegram) "
787
+ "VALUES (?,?,?,?,?,1,1)",
788
+ (alert_id, symbol, cond, val, f"{symbol} {cond.replace('_',' ')} {val}"),
789
+ )
790
+ conn.commit()
791
+ return f"✅ 预警已设置 `[{alert_id}]`\n{symbol} {cond.replace('_', ' ')} `{val}`"
792
+
793
+
794
+ def _list_alerts() -> str:
795
+ with sqlite3.connect(_DB_PATH) as conn:
796
+ conn.row_factory = sqlite3.Row
797
+ rows = conn.execute(
798
+ "SELECT id, symbol, condition, value, active, trigger_count FROM alerts ORDER BY created_at DESC LIMIT 15"
799
+ ).fetchall()
800
+ if not rows:
801
+ return "📭 暂无预警设置"
802
+ lines = ["*预警列表*\n"]
803
+ for r in rows:
804
+ status = "🟢" if r["active"] else "⚫"
805
+ lines.append(f"{status} `{r['id']}` {r['symbol']} {r['condition'].replace('_',' ')} `{r['value']}` (触发{r['trigger_count']}次)")
806
+ return "\n".join(lines)
807
+
808
+
809
+ def _daemon_status() -> str:
810
+ pid = os.getpid()
811
+ with sqlite3.connect(_DB_PATH) as conn:
812
+ alert_count = conn.execute("SELECT COUNT(*) FROM alerts WHERE active=1").fetchone()[0]
813
+ sched_count = conn.execute("SELECT COUNT(*) FROM schedules WHERE enabled=1").fetchone()[0]
814
+ device_count = conn.execute("SELECT COUNT(*) FROM device_tokens").fetchone()[0]
815
+ job_count = conn.execute("SELECT COUNT(*) FROM webhook_jobs WHERE status='pending'").fetchone()[0]
816
+ return (
817
+ f"*🤖 Aria Daemon 状态*\n"
818
+ f" PID: `{pid}`\n"
819
+ f" 活跃预警: `{alert_count}`\n"
820
+ f" 定时任务: `{sched_count}`\n"
821
+ f" 推送设备: `{device_count}`\n"
822
+ f" 待处理 Webhook: `{job_count}`\n"
823
+ f" 时间: `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}`"
824
+ )
825
+
826
+
827
+ async def _run_chat(text: str) -> str:
828
+ """Simple chat fallback using aria-code LLM if configured."""
829
+ return f"💬 _收到你的消息: \"{text[:80]}\"_\n\n请在 Terminal 启动 `/aria` 获得完整对话体验。"
830
+
831
+
832
+ # ── Webhook job executor ─────────────────────────────────────────────────────
833
+
834
+ async def _webhook_executor() -> None:
835
+ """Poll webhook_jobs table for pending jobs and execute them."""
836
+ logger.info("Webhook executor started")
837
+ while True:
838
+ try:
839
+ with sqlite3.connect(_DB_PATH) as conn:
840
+ conn.row_factory = sqlite3.Row
841
+ rows = conn.execute(
842
+ "SELECT * FROM webhook_jobs WHERE status='pending' ORDER BY created_at LIMIT 5"
843
+ ).fetchall()
844
+ for row in rows:
845
+ asyncio.create_task(_execute_job(dict(row)))
846
+ except Exception as exc:
847
+ logger.error("Webhook executor error: %s", exc)
848
+ await asyncio.sleep(2)
849
+
850
+
851
+ async def _webhook_http_server() -> None:
852
+ """Small local HTTP endpoint for TradingView webhooks."""
853
+ try:
854
+ from aiohttp import web
855
+ from apps.cli.tradingview_bridge import enqueue_tradingview_alert
856
+ except ImportError as exc:
857
+ logger.warning("Webhook HTTP server disabled: %s", exc)
858
+ return
859
+
860
+ token = os.environ.get("TRADINGVIEW_WEBHOOK_SECRET") or os.environ.get("WEBHOOK_TOKEN", "")
861
+ host = os.environ.get("ARIA_WEBHOOK_HOST", "127.0.0.1")
862
+ port = int(os.environ.get("ARIA_WEBHOOK_PORT", "8765"))
863
+
864
+ def _authorized(request) -> bool:
865
+ if not token:
866
+ return True
867
+ supplied = (
868
+ request.headers.get("X-Aria-Token")
869
+ or request.headers.get("X-TradingView-Secret")
870
+ or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
871
+ or request.query.get("token")
872
+ or request.query.get("secret")
873
+ )
874
+ return supplied == token
875
+
876
+ async def _handle_tradingview(request):
877
+ if not _authorized(request):
878
+ return web.json_response({"success": False, "error": "unauthorized"}, status=401)
879
+ text = await request.text()
880
+ try:
881
+ import json as _json
882
+ payload = _json.loads(text) if text.strip() else {}
883
+ except Exception:
884
+ payload = {"message": text}
885
+ if request.query.get("channels") and isinstance(payload, dict):
886
+ import json as _json
887
+ payload["channels"] = _json.dumps(
888
+ [item.strip() for item in request.query["channels"].split(",") if item.strip()]
889
+ )
890
+ if isinstance(payload, dict):
891
+ for key in ("broker_id", "quantity", "qty", "target_weight", "order_type"):
892
+ if request.query.get(key):
893
+ payload[key] = request.query[key]
894
+ result = enqueue_tradingview_alert(payload, db_path=_DB_PATH)
895
+ status = 202 if result.get("success") else 400
896
+ return web.json_response(result, status=status)
897
+
898
+ app = web.Application()
899
+ app.router.add_post("/api/v1/webhook/tradingview", _handle_tradingview)
900
+ app.router.add_post("/api/v1/tradingview/webhook", _handle_tradingview)
901
+
902
+ runner = web.AppRunner(app)
903
+ await runner.setup()
904
+ site = web.TCPSite(runner, host, port)
905
+ await site.start()
906
+ logger.info("TradingView webhook listening on http://%s:%d/api/v1/webhook/tradingview", host, port)
907
+ try:
908
+ while True:
909
+ await asyncio.sleep(3600)
910
+ finally:
911
+ await runner.cleanup()
912
+
913
+
914
+ async def _execute_job(job: dict) -> None:
915
+ job_id = job["id"]
916
+ command = job["command"]
917
+ try:
918
+ import json as _json
919
+ payload = _json.loads(job.get("payload") or "{}")
920
+ except Exception:
921
+ payload = {}
922
+
923
+ with sqlite3.connect(_DB_PATH) as conn:
924
+ conn.execute(
925
+ "UPDATE webhook_jobs SET status='running', started_at=datetime('now') WHERE id=?",
926
+ (job_id,),
927
+ )
928
+ conn.commit()
929
+
930
+ try:
931
+ if command.startswith("chat:"):
932
+ result = await _run_chat(command[5:])
933
+ elif command.startswith("report ") or command.startswith("/report "):
934
+ sym = command.split()[-1].upper()
935
+ result = await _run_report(sym)
936
+ elif command == "tradingview_alert":
937
+ result = await _run_tradingview_alert(payload)
938
+ elif command in ("morning-brief", "/morning-brief", "brief"):
939
+ result = await _run_morning_brief()
940
+ elif command in ("screen", "/screen"):
941
+ result = await _run_screener()
942
+ else:
943
+ raise ValueError(f"未知 Webhook 命令: {command!r}(支持: chat: / report / morning-brief / screen)")
944
+
945
+ with sqlite3.connect(_DB_PATH) as conn:
946
+ conn.execute(
947
+ "UPDATE webhook_jobs SET status='done', result=?, done_at=datetime('now') WHERE id=?",
948
+ (result[:2000], job_id),
949
+ )
950
+ conn.commit()
951
+
952
+ # If channels include telegram, send result
953
+ channels = []
954
+ try:
955
+ import json as _j
956
+ raw_channels = payload.get("channels", "[]")
957
+ if isinstance(raw_channels, list):
958
+ channels = raw_channels
959
+ else:
960
+ channels = _j.loads(raw_channels or "[]")
961
+ except Exception:
962
+ pass
963
+ if command == "tradingview_alert" and not channels:
964
+ channels = ["telegram", "feishu"]
965
+ if "telegram" in channels:
966
+ await _telegram_push(result)
967
+ if "feishu" in channels:
968
+ await _feishu_push("⏰ Webhook 任务完成", result[:2000])
969
+
970
+ except Exception as exc:
971
+ logger.error("Job %s failed: %s", job_id, exc)
972
+ with sqlite3.connect(_DB_PATH) as conn:
973
+ conn.execute(
974
+ "UPDATE webhook_jobs SET status='error', result=?, done_at=datetime('now') WHERE id=?",
975
+ (str(exc)[:500], job_id),
976
+ )
977
+ conn.commit()
978
+
979
+
980
+ # ── APScheduler cron ─────────────────────────────────────────────────────────
981
+
982
+ def _start_scheduler() -> None:
983
+ """Load schedules from DB and register with APScheduler."""
984
+ try:
985
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
986
+ from apscheduler.triggers.cron import CronTrigger
987
+
988
+ scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
989
+
990
+ with sqlite3.connect(_DB_PATH) as conn:
991
+ conn.row_factory = sqlite3.Row
992
+ rows = conn.execute(
993
+ "SELECT * FROM schedules WHERE enabled=1"
994
+ ).fetchall()
995
+
996
+ for row in rows:
997
+ s = dict(row)
998
+ try:
999
+ trigger = CronTrigger.from_crontab(s["cron_expr"], timezone="Asia/Shanghai")
1000
+ scheduler.add_job(
1001
+ _run_scheduled_job,
1002
+ trigger=trigger,
1003
+ args=[s],
1004
+ id=s["id"],
1005
+ name=s.get("name") or s["command"],
1006
+ replace_existing=True,
1007
+ misfire_grace_time=300,
1008
+ )
1009
+ logger.info("Scheduled: %s [%s]", s.get("name", s["id"]), s["cron_expr"])
1010
+ except Exception as exc:
1011
+ logger.error("Failed to schedule %s: %s", s["id"], exc)
1012
+
1013
+ # Default scheduled jobs (always register regardless of user schedules)
1014
+ # A-share pre-market brief: 08:55 weekdays (before 09:30 open)
1015
+ scheduler.add_job(
1016
+ _run_morning_brief_and_push,
1017
+ CronTrigger.from_crontab("55 8 * * 1-5", timezone="Asia/Shanghai"),
1018
+ id="default_morning_brief",
1019
+ name="A股开盘前晨报",
1020
+ replace_existing=True,
1021
+ misfire_grace_time=600,
1022
+ )
1023
+ # US pre-market brief: 21:00 weekdays CN time (US market opens 21:30 CN)
1024
+ scheduler.add_job(
1025
+ _run_morning_brief_and_push,
1026
+ CronTrigger.from_crontab("0 21 * * 1-5", timezone="Asia/Shanghai"),
1027
+ id="default_us_brief",
1028
+ name="美股开盘前简报",
1029
+ replace_existing=True,
1030
+ misfire_grace_time=600,
1031
+ )
1032
+ # A-share post-market evening brief: 15:35 weekdays
1033
+ scheduler.add_job(
1034
+ _run_evening_brief_and_push,
1035
+ CronTrigger.from_crontab("35 15 * * 1-5", timezone="Asia/Shanghai"),
1036
+ id="default_evening_brief",
1037
+ name="A股收盘晚报",
1038
+ replace_existing=True,
1039
+ misfire_grace_time=600,
1040
+ )
1041
+ # Weekly recap: Friday 16:30 CN time
1042
+ scheduler.add_job(
1043
+ _run_weekly_recap_and_push,
1044
+ CronTrigger.from_crontab("30 16 * * 5", timezone="Asia/Shanghai"),
1045
+ id="default_weekly_recap",
1046
+ name="周报",
1047
+ replace_existing=True,
1048
+ misfire_grace_time=1800,
1049
+ )
1050
+ logger.info("Registered 4 default scheduled jobs (morning / US / evening / weekly)")
1051
+
1052
+ scheduler.start()
1053
+ logger.info("APScheduler started with %d job(s)", len(scheduler.get_jobs()))
1054
+ return scheduler
1055
+ except ImportError:
1056
+ logger.warning("APScheduler not installed — cron disabled. pip install apscheduler")
1057
+ return None
1058
+
1059
+
1060
+ async def _run_scheduled_job(schedule: dict) -> None:
1061
+ logger.info("Running scheduled job: %s", schedule.get("name", schedule["id"]))
1062
+ import json as _j
1063
+ channels = _j.loads(schedule.get("channels") or '["ios","telegram"]')
1064
+ cmd = schedule["command"]
1065
+ result = ""
1066
+ try:
1067
+ if cmd in ("morning-brief", "morning"):
1068
+ result = await _run_morning_brief()
1069
+ elif cmd in ("evening-brief", "evening"):
1070
+ result = await _run_evening_brief()
1071
+ elif cmd in ("weekly-recap", "weekly"):
1072
+ result = await _run_weekly_recap()
1073
+ elif cmd == "screen":
1074
+ result = await _run_screener()
1075
+ elif cmd.startswith("report "):
1076
+ result = await _run_report(cmd.split()[-1].upper())
1077
+ elif cmd.startswith("custom:"):
1078
+ result = f"Custom job: {cmd[7:]}"
1079
+ else:
1080
+ raise ValueError(
1081
+ f"未知定时命令: {cmd!r}(支持: morning-brief / evening-brief / weekly-recap / screen / report <symbol> / custom:<payload>)"
1082
+ )
1083
+ except Exception as exc:
1084
+ result = f"⚠️ 定时任务 [{cmd}] 失败: {exc}"
1085
+
1086
+ if "telegram" in channels:
1087
+ await _telegram_push(f"⏰ *定时任务完成*\n{result}")
1088
+ if "feishu" in channels:
1089
+ await _feishu_push(f"⏰ 定时任务:{cmd}", result[:2000])
1090
+ if "ios" in channels:
1091
+ await _push_alert(
1092
+ "Aria 定时任务",
1093
+ result[:150],
1094
+ {"command": cmd},
1095
+ )
1096
+
1097
+ with sqlite3.connect(_DB_PATH) as conn:
1098
+ conn.execute(
1099
+ "UPDATE schedules SET last_run=datetime('now') WHERE id=?",
1100
+ (schedule["id"],),
1101
+ )
1102
+ conn.commit()
1103
+
1104
+
1105
+ async def _run_morning_brief_and_push() -> None:
1106
+ brief = await _run_morning_brief()
1107
+ await _telegram_push(brief)
1108
+ await _feishu_push("📊 Aria 晨报", brief[:2000])
1109
+ await _push_alert("Aria 晨报", brief[:150])
1110
+ _system_notify("Aria 晨报", brief[:120])
1111
+
1112
+
1113
+ async def _run_evening_brief_and_push() -> None:
1114
+ brief = await _run_evening_brief()
1115
+ await _telegram_push(brief)
1116
+ await _feishu_push("🌆 Aria 收盘简报", brief[:2000])
1117
+ await _push_alert("Aria 收盘", brief[:150])
1118
+ _system_notify("Aria 收盘简报", brief[:120])
1119
+
1120
+
1121
+ async def _run_weekly_recap_and_push() -> None:
1122
+ recap = await _run_weekly_recap()
1123
+ await _telegram_push(recap)
1124
+ await _feishu_push("📅 Aria 周报", recap[:2000])
1125
+ await _push_alert("Aria 周报", recap[:150])
1126
+ _system_notify("Aria 周报", recap[:120])
1127
+
1128
+
1129
+ # ── PID management ────────────────────────────────────────────────────────────
1130
+
1131
+ def _write_pid() -> None:
1132
+ _ARIA_DIR.mkdir(parents=True, exist_ok=True)
1133
+ _PID_FILE.write_text(str(os.getpid()))
1134
+
1135
+
1136
+ def _remove_pid() -> None:
1137
+ try:
1138
+ _PID_FILE.unlink(missing_ok=True)
1139
+ except Exception:
1140
+ pass
1141
+
1142
+
1143
+ # ── Install / uninstall as macOS LaunchAgent ──────────────────────────────────
1144
+
1145
+ def _install_launchagent() -> None:
1146
+ python = sys.executable
1147
+ script = str(Path(__file__).resolve())
1148
+ log_out = str(_LOG_DIR / "daemon.log")
1149
+ log_err = str(_LOG_DIR / "daemon.err")
1150
+ plist_dir = Path.home() / "Library" / "LaunchAgents"
1151
+ plist_dir.mkdir(parents=True, exist_ok=True)
1152
+ plist_path = plist_dir / "com.aria.daemon.plist"
1153
+
1154
+ plist = f"""<?xml version="1.0" encoding="UTF-8"?>
1155
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
1156
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1157
+ <plist version="1.0">
1158
+ <dict>
1159
+ <key>Label</key>
1160
+ <string>com.aria.daemon</string>
1161
+ <key>ProgramArguments</key>
1162
+ <array>
1163
+ <string>{python}</string>
1164
+ <string>{script}</string>
1165
+ </array>
1166
+ <key>RunAtLoad</key>
1167
+ <true/>
1168
+ <key>KeepAlive</key>
1169
+ <true/>
1170
+ <key>StandardOutPath</key>
1171
+ <string>{log_out}</string>
1172
+ <key>StandardErrorPath</key>
1173
+ <string>{log_err}</string>
1174
+ <key>EnvironmentVariables</key>
1175
+ <dict>
1176
+ <key>PATH</key>
1177
+ <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:{str(Path(python).parent)}</string>
1178
+ <key>ARIA_CODE_DIR</key>
1179
+ <string>{str(_ARIA_CODE_DIR)}</string>
1180
+ </dict>
1181
+ <key>ThrottleInterval</key>
1182
+ <integer>30</integer>
1183
+ </dict>
1184
+ </plist>
1185
+ """
1186
+ plist_path.write_text(plist)
1187
+ os.system(f"launchctl unload '{plist_path}' 2>/dev/null; launchctl load -w '{plist_path}'")
1188
+ print(f"✅ Aria Daemon installed as LaunchAgent: {plist_path}")
1189
+ print(f" Logs: {log_out}")
1190
+ print(f" To uninstall: python3 {script} --uninstall")
1191
+
1192
+
1193
+ def _uninstall_launchagent() -> None:
1194
+ plist_path = Path.home() / "Library" / "LaunchAgents" / "com.aria.daemon.plist"
1195
+ if plist_path.exists():
1196
+ os.system(f"launchctl unload '{plist_path}' 2>/dev/null")
1197
+ plist_path.unlink()
1198
+ print("✅ Aria Daemon LaunchAgent removed")
1199
+ else:
1200
+ print("ℹ️ LaunchAgent not installed")
1201
+ _remove_pid()
1202
+
1203
+
1204
+ # ── Main ──────────────────────────────────────────────────────────────────────
1205
+
1206
+ async def main() -> None:
1207
+ logger.info("═" * 55)
1208
+ logger.info(" Aria Daemon starting PID=%d", os.getpid())
1209
+ logger.info("═" * 55)
1210
+ _write_pid()
1211
+
1212
+ loop = asyncio.get_event_loop()
1213
+ # Graceful shutdown on SIGINT / SIGTERM
1214
+ for sig in (signal.SIGINT, signal.SIGTERM):
1215
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(_shutdown()))
1216
+
1217
+ tasks = [
1218
+ asyncio.create_task(_alert_watchdog(), name="alert_watchdog"),
1219
+ asyncio.create_task(_webhook_executor(), name="webhook_executor"),
1220
+ asyncio.create_task(_webhook_http_server(), name="webhook_http_server"),
1221
+ asyncio.create_task(_portfolio_loss_watchdog(), name="portfolio_watchdog"),
1222
+ ]
1223
+
1224
+ scheduler = _start_scheduler()
1225
+
1226
+ # Start Telegram bot if token configured
1227
+ tg_token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
1228
+ if tg_token:
1229
+ from aria_telegram_bot import TelegramBot
1230
+ allowed_raw = os.environ.get("TELEGRAM_ALLOWED_IDS", "")
1231
+ allowed_ids = set(
1232
+ int(x.strip()) for x in allowed_raw.split(",") if x.strip().isdigit()
1233
+ )
1234
+ bot = TelegramBot(token=tg_token, allowed_chat_ids=allowed_ids)
1235
+ me = await bot.get_me()
1236
+ if me:
1237
+ logger.info("Telegram bot: @%s", me.get("username", "?"))
1238
+ await _telegram_push("🤖 *Aria Daemon 已启动*\n发送 `/help` 查看命令。")
1239
+ tasks.append(asyncio.create_task(bot.start(_telegram_command), name="telegram_bot"))
1240
+ else:
1241
+ logger.info("TELEGRAM_BOT_TOKEN not set — Telegram bot disabled")
1242
+
1243
+ # Start Feishu relay client if relay mode configured
1244
+ relay_url = os.environ.get("ARIA_RELAY_URL", "")
1245
+ relay_client_id = os.environ.get("ARIA_RELAY_CLIENT_ID", "")
1246
+ relay_mode = os.environ.get("ARIA_RELAY_MODE", "")
1247
+ if relay_url and relay_client_id and relay_mode == "relay":
1248
+ try:
1249
+ from aria_relay_client import _connect_and_serve as _relay_serve
1250
+ tasks.append(asyncio.create_task(_relay_serve(), name="feishu_relay"))
1251
+ logger.info("Feishu relay client started → %s (client=%s)", relay_url, relay_client_id)
1252
+ except ImportError:
1253
+ logger.warning("aria_relay_client.py not found — Feishu relay disabled")
1254
+
1255
+ logger.info("All workers started. Daemon running.")
1256
+
1257
+ try:
1258
+ await asyncio.gather(*tasks)
1259
+ except asyncio.CancelledError:
1260
+ pass
1261
+ finally:
1262
+ _remove_pid()
1263
+ if scheduler:
1264
+ scheduler.shutdown(wait=False)
1265
+ logger.info("Aria Daemon stopped")
1266
+
1267
+
1268
+ async def _shutdown() -> None:
1269
+ logger.info("Shutdown signal received")
1270
+ for task in asyncio.all_tasks():
1271
+ if task.get_name() not in ("main_task",):
1272
+ task.cancel()
1273
+
1274
+
1275
+ if __name__ == "__main__":
1276
+ if "--install" in sys.argv:
1277
+ _install_launchagent()
1278
+ elif "--uninstall" in sys.argv:
1279
+ _uninstall_launchagent()
1280
+ elif "--status" in sys.argv:
1281
+ pid_file = _ARIA_DIR / "daemon.pid"
1282
+ if pid_file.exists():
1283
+ try:
1284
+ pid = int(pid_file.read_text().strip())
1285
+ os.kill(pid, 0)
1286
+ print(f"✅ Aria Daemon is running (PID {pid})")
1287
+ except ProcessLookupError:
1288
+ print("⚠️ PID file exists but process not running")
1289
+ else:
1290
+ print("⚫ Aria Daemon is not running")
1291
+ else:
1292
+ try:
1293
+ asyncio.run(main())
1294
+ except KeyboardInterrupt:
1295
+ pass