marketmyna 0.2.0__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 (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,394 @@
1
+ """Updates (Settings), against github.com/pryndor/MarketMyna.
2
+
3
+ Checking:
4
+ - releases: the running version against the latest release (every install);
5
+ - the main branch: a git checkout also compares its commit with GitHub's
6
+ latest, so new code shows up before it is released.
7
+ A background thread checks on its own every few minutes (5 min, 1 h, 2 h or
8
+ off, saved in ~/.marketmyna/updates.json), so the sidebar can say an update
9
+ is waiting without anyone pressing a button.
10
+
11
+ Updating ("Update now"): a git checkout fetches main and moves to it (a
12
+ backup branch keeps the old commit; local edits stop it), a pipx / pip install
13
+ upgrades the package; both then install the requirements and restart the
14
+ app. A release zip is updated by hand (download the new zip).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ import time
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ import httpx
31
+ from fastapi import APIRouter, Depends, HTTPException
32
+ from pydantic import BaseModel
33
+
34
+ from marketmyna import __version__
35
+ from marketmyna.api.middlewares.auth import require_admin
36
+ from marketmyna.core.home import app_home
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ router = APIRouter(prefix="/system/updates", dependencies=[Depends(require_admin)])
41
+
42
+ REPO = "pryndor/MarketMyna"
43
+ BRANCH = "main"
44
+ REPO_GIT = f"https://github.com/{REPO}.git"
45
+ RELEASES_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
46
+ COMMIT_URL = f"https://api.github.com/repos/{REPO}/commits/{BRANCH}"
47
+ RELEASES_PAGE = f"https://github.com/{REPO}/releases"
48
+ REPO_PAGE = f"https://github.com/{REPO}"
49
+ # GitHub allows 60 unauthenticated calls an hour; a check is 2 calls, so even
50
+ # every 5 minutes uses under half. Answers younger than this are reused.
51
+ CACHE_SECONDS = 240
52
+ _cache: dict = {"at": 0.0, "release": None, "commit_at": 0.0, "commit": None}
53
+
54
+ # How often the background thread checks, in minutes (0 = off).
55
+ INTERVALS = (0, 5, 60, 120)
56
+ DEFAULT_INTERVAL = 5
57
+
58
+ ROOT = Path(__file__).resolve().parents[4]
59
+
60
+
61
+ def parse_version(text: str) -> tuple[int, ...]:
62
+ """'v0.10.2' -> (0, 10, 2); anything after the numbers (rc1, -beta) is ignored."""
63
+ match = re.match(r"\s*v?(\d+(?:\.\d+)*)", text or "")
64
+ return tuple(int(part) for part in match.group(1).split(".")) if match else ()
65
+
66
+
67
+ def is_newer(latest: str, current: str) -> bool:
68
+ a, b = parse_version(latest), parse_version(current)
69
+ width = max(len(a), len(b))
70
+ return (a + (0,) * (width - len(a))) > (b + (0,) * (width - len(b)))
71
+
72
+
73
+ def install_kind() -> str:
74
+ """'source': a git checkout; 'zip': a release zip run with start.py; 'package': pipx / pip."""
75
+ if not (ROOT / "start.py").exists():
76
+ return "package"
77
+ return "source" if (ROOT / ".git").exists() else "zip"
78
+
79
+
80
+ UPDATE_STEPS = {
81
+ "source": ["git pull", "pip install -r requirements-web.txt", "python start.py --rebuild"],
82
+ "zip": ["Download the new zip from the releases page, unpack it, then run: pip install -r requirements-web.txt && python start.py"],
83
+ "package": ["pipx upgrade marketmyna"],
84
+ }
85
+
86
+
87
+ # ------------------------------------------------------------------ settings
88
+
89
+ def _settings_file() -> Path:
90
+ return app_home() / "updates.json"
91
+
92
+
93
+ def load_settings() -> dict:
94
+ try:
95
+ data = json.loads(_settings_file().read_text(encoding="utf-8"))
96
+ except (OSError, ValueError):
97
+ data = {}
98
+ interval = data.get("interval_minutes", DEFAULT_INTERVAL)
99
+ return {"interval_minutes": interval if interval in INTERVALS else DEFAULT_INTERVAL}
100
+
101
+
102
+ def save_settings(settings: dict) -> None:
103
+ path = _settings_file()
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(json.dumps(settings), encoding="utf-8")
106
+
107
+
108
+ # ------------------------------------------------------------------ GitHub
109
+
110
+ def _github(url: str):
111
+ response = httpx.get(
112
+ url,
113
+ headers={"Accept": "application/vnd.github+json", "User-Agent": f"MarketMyna/{__version__}"},
114
+ timeout=10,
115
+ follow_redirects=True,
116
+ )
117
+ if response.status_code == 404:
118
+ return None
119
+ response.raise_for_status()
120
+ return response.json()
121
+
122
+
123
+ def _latest_release(force: bool = False) -> dict | None:
124
+ """The newest published release, or None when the repo has none yet."""
125
+ now = time.time()
126
+ if not force and _cache["at"] and now - _cache["at"] < CACHE_SECONDS:
127
+ return _cache["release"]
128
+ data = _github(RELEASES_URL)
129
+ release = None
130
+ if data:
131
+ release = {
132
+ "version": str(data.get("tag_name") or "").lstrip("v"),
133
+ "name": data.get("name") or data.get("tag_name") or "",
134
+ "url": data.get("html_url") or RELEASES_PAGE,
135
+ "published_at": data.get("published_at"),
136
+ "notes": (data.get("body") or "")[:4000],
137
+ }
138
+ _cache.update(at=now, release=release)
139
+ return release
140
+
141
+
142
+ def _latest_commit(force: bool = False) -> dict | None:
143
+ """The newest commit on GitHub's main branch."""
144
+ now = time.time()
145
+ if not force and _cache["commit_at"] and now - _cache["commit_at"] < CACHE_SECONDS:
146
+ return _cache["commit"]
147
+ data = _github(COMMIT_URL)
148
+ commit = None
149
+ if data:
150
+ info = data.get("commit") or {}
151
+ commit = {
152
+ "sha": data.get("sha") or "",
153
+ "date": (info.get("committer") or {}).get("date"),
154
+ "message": (info.get("message") or "").split("\n")[0][:200],
155
+ "url": data.get("html_url") or REPO_PAGE,
156
+ }
157
+ _cache.update(commit_at=now, commit=commit)
158
+ return commit
159
+
160
+
161
+ def _git(*args: str, timeout: int = 60) -> subprocess.CompletedProcess:
162
+ return subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True, timeout=timeout)
163
+
164
+
165
+ def local_commit() -> dict | None:
166
+ """This checkout's commit (sha and unix time), or None without git."""
167
+ try:
168
+ out = _git("log", "-1", "--format=%H %ct", timeout=5).stdout.split()
169
+ return {"sha": out[0], "time": int(out[1])}
170
+ except (OSError, subprocess.SubprocessError, IndexError, ValueError):
171
+ return None
172
+
173
+
174
+ def code_status(local: dict | None, remote: dict | None) -> dict | None:
175
+ """Is GitHub's main branch newer than this checkout? Newer by commit time,
176
+ so local work that is not pushed yet never reads as an update."""
177
+ if not local or not remote or not remote.get("sha"):
178
+ return None
179
+ try:
180
+ remote_time = datetime.fromisoformat(str(remote["date"]).replace("Z", "+00:00")).timestamp()
181
+ except (TypeError, ValueError):
182
+ remote_time = 0
183
+ same = remote["sha"] == local["sha"]
184
+ return {
185
+ "local_sha": local["sha"][:7],
186
+ "remote_sha": remote["sha"][:7],
187
+ "remote_date": remote.get("date"),
188
+ "message": remote.get("message"),
189
+ "url": remote.get("url"),
190
+ "update_available": not same and remote_time > local["time"],
191
+ }
192
+
193
+
194
+ # The last result, from a button press or the background thread.
195
+ _last: dict = {}
196
+
197
+
198
+ def check(force: bool = False) -> dict:
199
+ kind = install_kind()
200
+ result = {
201
+ "current": __version__,
202
+ "install": kind,
203
+ "commands": UPDATE_STEPS[kind],
204
+ "can_apply": kind in ("source", "package"),
205
+ "repo_url": REPO_PAGE,
206
+ "releases_url": RELEASES_PAGE,
207
+ "checked_at": int(time.time()),
208
+ "settings": load_settings(),
209
+ }
210
+ try:
211
+ release = _latest_release(force)
212
+ code = code_status(local_commit(), _latest_commit(force)) if kind == "source" else None
213
+ except (httpx.HTTPError, ValueError) as exc:
214
+ result.update(latest=None, release=None, release_available=False, code=None, update_available=False,
215
+ error=f"Could not reach GitHub: {exc}")
216
+ else:
217
+ release_newer = bool(release and release["version"]) and is_newer(release["version"], __version__)
218
+ result.update(
219
+ latest=release["version"] if release else None,
220
+ release=release,
221
+ release_available=release_newer,
222
+ code=code,
223
+ # A git checkout follows main; a package / zip install follows releases.
224
+ update_available=release_newer or bool(code and code["update_available"]),
225
+ error=None,
226
+ )
227
+ _last.clear()
228
+ _last.update(result)
229
+ return result
230
+
231
+
232
+ def check_loop(first_delay: int = 30) -> None:
233
+ """Background thread: check GitHub at the chosen interval (re-read each round)."""
234
+ if os.environ.get("PYTEST_CURRENT_TEST"):
235
+ return # tests never call GitHub
236
+ time.sleep(first_delay)
237
+ while True:
238
+ minutes = load_settings()["interval_minutes"]
239
+ if minutes:
240
+ try:
241
+ result = check()
242
+ if result.get("update_available"):
243
+ log.info("MarketMyna update available on GitHub (%s)", result.get("latest") or (result.get("code") or {}).get("remote_sha"))
244
+ except Exception: # never let the thread die
245
+ log.exception("Update check failed")
246
+ time.sleep(max(minutes, 5) * 60)
247
+
248
+
249
+ # ------------------------------------------------------------------ applying
250
+
251
+ _apply_lock = threading.Lock()
252
+
253
+
254
+ def _run(cmd: list[str], logs: list[str], cwd: Path | None = None, timeout: int = 900) -> None:
255
+ logs.append("$ " + " ".join(cmd))
256
+ proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
257
+ output = (proc.stdout + proc.stderr).strip()
258
+ if output:
259
+ logs.append(output[-3000:])
260
+ if proc.returncode != 0:
261
+ raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode})")
262
+
263
+
264
+ def apply_source(logs: list[str]) -> None:
265
+ """Move this checkout to GitHub's main. Fast-forward when possible; when the
266
+ history was rewritten, keep the old commit on a backup branch first."""
267
+ if _git("status", "--porcelain", "--untracked-files=no", timeout=10).stdout.strip():
268
+ raise RuntimeError("This copy has local changes to MarketMyna's files. Commit or stash them, then update.")
269
+ _run(["git", "-C", str(ROOT), "fetch", REPO_GIT, BRANCH], logs)
270
+ head = _git("rev-parse", "HEAD", timeout=10).stdout.strip()
271
+ target = _git("rev-parse", "FETCH_HEAD", timeout=10).stdout.strip()
272
+ if head == target:
273
+ logs.append("Already at the latest code.")
274
+ elif _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", timeout=10).returncode == 0:
275
+ _run(["git", "-C", str(ROOT), "merge", "--ff-only", "FETCH_HEAD"], logs)
276
+ else:
277
+ backup = f"marketmyna-backup-{time.strftime('%Y%m%d-%H%M%S')}"
278
+ _run(["git", "-C", str(ROOT), "branch", backup, "HEAD"], logs)
279
+ logs.append(f"Your previous commit is kept on branch {backup}.")
280
+ _run(["git", "-C", str(ROOT), "reset", "--hard", "FETCH_HEAD"], logs)
281
+ changed = _git("diff", "--name-only", head, "HEAD", timeout=10).stdout.split()
282
+ if "requirements-web.txt" in changed:
283
+ install_requirements(logs)
284
+ else:
285
+ logs.append("Requirements unchanged; nothing to install.")
286
+
287
+
288
+ def install_requirements(logs: list[str]) -> None:
289
+ """pip install the requirements. A Python that refuses pip (a system Python,
290
+ PEP 668 "externally managed") is reported, not treated as a failed update."""
291
+ cmd = [sys.executable, "-m", "pip", "install", "-q", "-r", "requirements-web.txt"]
292
+ logs.append("$ " + " ".join(cmd))
293
+ proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=900)
294
+ output = (proc.stdout + proc.stderr).strip()
295
+ if proc.returncode == 0:
296
+ if output:
297
+ logs.append(output[-3000:])
298
+ return
299
+ if "externally-managed-environment" in output:
300
+ logs.append("This Python does not allow pip installs (a system Python). The new requirements were "
301
+ "not installed: run `pip install -r requirements-web.txt` in your virtual environment.")
302
+ return
303
+ logs.append(output[-3000:])
304
+ raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode})")
305
+
306
+
307
+ def apply_package(logs: list[str]) -> None:
308
+ _run([sys.executable, "-m", "pip", "install", "-q", "--upgrade", "marketmyna"], logs)
309
+
310
+
311
+ def restart_command(kind: str, argv: list[str]) -> list[str]:
312
+ """How to start this app again with the same options (and no new browser tab)."""
313
+ args = [a for a in argv if a != "--rebuild"]
314
+ if "--no-browser" not in args:
315
+ args.append("--no-browser")
316
+ if kind == "source":
317
+ return [sys.executable, str(ROOT / "start.py"), *args]
318
+ return [sys.executable, "-m", "marketmyna.launcher", *args]
319
+
320
+
321
+ def restart(kind: str, delay: float = 1.5) -> None:
322
+ """Start the app again with the same options. Linux / macOS replace this
323
+ process in place (same terminal, Ctrl+C still works; the listening socket is
324
+ closed on exec, so the port is free). Windows starts a fresh copy once this
325
+ one has exited. A source install rebuilds the frontend on that start."""
326
+ cmd = restart_command(kind, sys.argv[1:])
327
+ cwd = str(ROOT) if kind == "source" else os.getcwd()
328
+
329
+ def go() -> None:
330
+ time.sleep(delay) # let the HTTP answer reach the browser
331
+ sys.stdout.flush()
332
+ sys.stderr.flush()
333
+ if os.name != "nt":
334
+ os.chdir(cwd)
335
+ os.execv(cmd[0], cmd)
336
+ helper = f"import subprocess, time; time.sleep(3); subprocess.Popen({cmd!r}, cwd={cwd!r})"
337
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
338
+ subprocess.Popen([sys.executable, "-c", helper], creationflags=flags)
339
+ os._exit(0)
340
+
341
+ threading.Thread(target=go, name="update-restart", daemon=True).start()
342
+
343
+
344
+ # ------------------------------------------------------------------ routes
345
+
346
+ @router.get("")
347
+ def get_updates(force: bool = False) -> dict:
348
+ """force=true (the button) asks GitHub now; otherwise a recent answer is reused."""
349
+ return check(force=force)
350
+
351
+
352
+ @router.get("/status")
353
+ def last_status() -> dict:
354
+ """What the background thread found last (for the sidebar); never calls GitHub."""
355
+ return dict(_last) if _last else {"current": __version__, "update_available": False, "checked_at": None}
356
+
357
+
358
+ class UpdateSettingsIn(BaseModel):
359
+ interval_minutes: int
360
+
361
+
362
+ @router.put("/settings")
363
+ def put_settings(body: UpdateSettingsIn) -> dict:
364
+ if body.interval_minutes not in INTERVALS:
365
+ raise HTTPException(status_code=400, detail=f"interval_minutes must be one of {list(INTERVALS)}")
366
+ settings = {"interval_minutes": body.interval_minutes}
367
+ save_settings(settings)
368
+ return settings
369
+
370
+
371
+ @router.post("/apply")
372
+ def apply_update() -> dict:
373
+ kind = install_kind()
374
+ if kind == "zip":
375
+ raise HTTPException(status_code=400, detail="A release-zip copy is updated by downloading the new zip from the releases page.")
376
+ if not _apply_lock.acquire(blocking=False):
377
+ raise HTTPException(status_code=409, detail="An update is already running.")
378
+ logs: list[str] = []
379
+ try:
380
+ status = check(force=True)
381
+ if status.get("error"):
382
+ raise HTTPException(status_code=502, detail=status["error"])
383
+ if not status.get("update_available"):
384
+ return {"updated": False, "restarting": False, "log": ["Already up to date."]}
385
+ try:
386
+ (apply_source if kind == "source" else apply_package)(logs)
387
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
388
+ tail = "\n".join(logs[-6:])
389
+ raise HTTPException(status_code=500, detail=f"{exc}\n\n{tail}".strip()) from exc
390
+ logs.append("Update installed. Restarting MarketMyna...")
391
+ restart(kind)
392
+ return {"updated": True, "restarting": True, "log": logs}
393
+ finally:
394
+ _apply_lock.release()
@@ -0,0 +1,179 @@
1
+ """Watchlists: named lists of instruments to track (no quantities or cost -
2
+ that's what portfolios are for). Items are enriched with live price data
3
+ from the market listing cache (marketmyna/data/listings.py)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Response
10
+ from pydantic import BaseModel, Field
11
+ from sqlalchemy.exc import IntegrityError
12
+ from sqlalchemy.orm import Session
13
+
14
+ from marketmyna.api.deps import get_config_dep, get_db_session
15
+ from marketmyna.data.listings import MARKET_LABELS, get_store
16
+ from marketmyna.storage import WatchlistItemRecord, WatchlistRecord
17
+
18
+ router = APIRouter()
19
+
20
+ DEFAULT_WATCHLIST = "My watchlist"
21
+
22
+ # Listing fields copied onto each item so the page can render a full row.
23
+ _LIVE_FIELDS = (
24
+ "name", "exchange", "sector", "category", "category_inferred", "price", "change",
25
+ "change_percent", "volume", "market_cap", "high_52w", "low_52w", "currency",
26
+ "name_local", "amc", "fund_group", "plan", "option", "isin", "nav_date",
27
+ )
28
+
29
+
30
+ class WatchlistBody(BaseModel):
31
+ name: str = Field(min_length=1, max_length=60)
32
+
33
+
34
+ class ItemBody(BaseModel):
35
+ symbol: str = Field(min_length=1, max_length=40)
36
+ market: str = "us"
37
+ type: str = "stock"
38
+ name: str = ""
39
+ note: str = ""
40
+
41
+
42
+ def _summary(record: WatchlistRecord) -> dict:
43
+ return {"id": record.id, "name": record.name, "count": len(record.items)}
44
+
45
+
46
+ def _all(db: Session) -> list[WatchlistRecord]:
47
+ records = db.query(WatchlistRecord).order_by(WatchlistRecord.id).all()
48
+ if not records:
49
+ # Everyone starts with one list, so "add to watchlist" always has a target.
50
+ # A savepoint, so a parallel first request creating the same list
51
+ # (unique per user) only loses its insert, not the whole request.
52
+ try:
53
+ with db.begin_nested():
54
+ db.add(WatchlistRecord(name=DEFAULT_WATCHLIST))
55
+ except IntegrityError:
56
+ pass
57
+ records = db.query(WatchlistRecord).order_by(WatchlistRecord.id).all()
58
+ return records
59
+
60
+
61
+ def _get(db: Session, watchlist_id: int) -> WatchlistRecord:
62
+ record = db.get(WatchlistRecord, watchlist_id)
63
+ if record is None:
64
+ raise HTTPException(status_code=404, detail=f"Watchlist {watchlist_id} not found")
65
+ return record
66
+
67
+
68
+ def _item_dict(item: WatchlistItemRecord, live: Optional[dict]) -> dict:
69
+ out = {
70
+ "id": item.id,
71
+ "watchlist_id": item.watchlist_id,
72
+ "symbol": item.symbol,
73
+ "market": item.market,
74
+ "type": item.type,
75
+ "name": item.name or item.symbol,
76
+ "note": item.note or "",
77
+ "added_at": item.added_at.isoformat() if item.added_at else None,
78
+ "listed": live is not None,
79
+ }
80
+ if live:
81
+ out.update({k: live.get(k) for k in _LIVE_FIELDS if k in live})
82
+ return out
83
+
84
+
85
+ def _unique_name(db: Session, name: str, exclude_id: Optional[int] = None) -> str:
86
+ name = name.strip()
87
+ if not name:
88
+ raise HTTPException(status_code=400, detail="Name cannot be empty")
89
+ clash = db.query(WatchlistRecord).filter(WatchlistRecord.name == name).first()
90
+ if clash and clash.id != exclude_id:
91
+ raise HTTPException(status_code=409, detail=f"A watchlist named '{name}' already exists")
92
+ return name
93
+
94
+
95
+ @router.get("/watchlists")
96
+ def list_watchlists(db: Session = Depends(get_db_session), config=Depends(get_config_dep)) -> list[dict]:
97
+ return [_summary(r) for r in _all(db)]
98
+
99
+
100
+ @router.post("/watchlists", status_code=201)
101
+ def create_watchlist(body: WatchlistBody, db: Session = Depends(get_db_session),
102
+ config=Depends(get_config_dep)) -> dict:
103
+ record = WatchlistRecord(name=_unique_name(db, body.name))
104
+ db.add(record)
105
+ db.flush()
106
+ return _summary(record)
107
+
108
+
109
+ @router.patch("/watchlists/{watchlist_id}")
110
+ def rename_watchlist(watchlist_id: int, body: WatchlistBody, db: Session = Depends(get_db_session)) -> dict:
111
+ record = _get(db, watchlist_id)
112
+ record.name = _unique_name(db, body.name, exclude_id=record.id)
113
+ return _summary(record)
114
+
115
+
116
+ @router.delete("/watchlists/{watchlist_id}", status_code=204)
117
+ def delete_watchlist(watchlist_id: int, db: Session = Depends(get_db_session)) -> Response:
118
+ db.delete(_get(db, watchlist_id))
119
+ return Response(status_code=204)
120
+
121
+
122
+ @router.get("/watchlists/items")
123
+ def all_items(db: Session = Depends(get_db_session)) -> list[dict]:
124
+ """Every item across lists (no live data) - lets the UI mark stars."""
125
+ _all(db)
126
+ # The join keeps items to the caller's own lists (watchlists are user-scoped).
127
+ items = (
128
+ db.query(WatchlistItemRecord)
129
+ .join(WatchlistRecord, WatchlistItemRecord.watchlist_id == WatchlistRecord.id)
130
+ .order_by(WatchlistItemRecord.id)
131
+ .all()
132
+ )
133
+ return [
134
+ {"id": i.id, "watchlist_id": i.watchlist_id, "symbol": i.symbol, "market": i.market, "type": i.type}
135
+ for i in items
136
+ ]
137
+
138
+
139
+ @router.get("/watchlists/{watchlist_id}")
140
+ def get_watchlist(watchlist_id: int, db: Session = Depends(get_db_session), config=Depends(get_config_dep)) -> dict:
141
+ record = _get(db, watchlist_id)
142
+ keys = [(i.market, i.type, i.symbol) for i in record.items if i.market in MARKET_LABELS]
143
+ live = get_store().lookup(keys) if keys else {}
144
+ return {
145
+ **_summary(record),
146
+ "items": [_item_dict(i, live.get((i.market, i.type, i.symbol))) for i in record.items],
147
+ }
148
+
149
+
150
+ @router.post("/watchlists/{watchlist_id}/items", status_code=201)
151
+ def add_item(watchlist_id: int, body: ItemBody, db: Session = Depends(get_db_session),
152
+ config=Depends(get_config_dep)) -> dict:
153
+ record = _get(db, watchlist_id)
154
+ symbol = body.symbol.strip().upper() if body.type != "mf" else body.symbol.strip()
155
+ market = body.market.strip().lower()
156
+ existing = next(
157
+ (i for i in record.items if i.symbol == symbol and i.market == market and i.type == body.type), None
158
+ )
159
+ if existing:
160
+ return _item_dict(existing, None)
161
+ item = WatchlistItemRecord(
162
+ watchlist_id=record.id, symbol=symbol, market=market, type=body.type, name=body.name, note=body.note
163
+ )
164
+ db.add(item)
165
+ try:
166
+ db.flush()
167
+ except IntegrityError:
168
+ raise HTTPException(status_code=409, detail=f"{symbol} is already in this watchlist")
169
+ return _item_dict(item, None)
170
+
171
+
172
+ @router.delete("/watchlists/{watchlist_id}/items/{item_id}", status_code=204)
173
+ def remove_item(watchlist_id: int, item_id: int, db: Session = Depends(get_db_session)) -> Response:
174
+ record = _get(db, watchlist_id) # 404s for someone else's list
175
+ item = db.get(WatchlistItemRecord, item_id)
176
+ if item is None or item.watchlist_id != record.id:
177
+ raise HTTPException(status_code=404, detail="Item not found")
178
+ db.delete(item)
179
+ return Response(status_code=204)
@@ -0,0 +1,43 @@
1
+ """Aggregates all v1 endpoint routers."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from marketmyna.api.v1.endpoints import (
6
+ agent,
7
+ bot,
8
+ calendar,
9
+ data_sources,
10
+ health,
11
+ listings,
12
+ macro,
13
+ portfolio,
14
+ push,
15
+ reports,
16
+ scheduler,
17
+ stocks,
18
+ system_config,
19
+ thesis,
20
+ trade,
21
+ updates,
22
+ watchlists,
23
+ )
24
+
25
+ router = APIRouter()
26
+
27
+ router.include_router(health.router, tags=["health"])
28
+ router.include_router(data_sources.router, tags=["data-sources"])
29
+ router.include_router(stocks.router, tags=["stocks"])
30
+ router.include_router(listings.router, tags=["listings"])
31
+ router.include_router(portfolio.router, tags=["portfolio"])
32
+ router.include_router(watchlists.router, tags=["watchlists"])
33
+ router.include_router(system_config.router, tags=["system-config"])
34
+ router.include_router(agent.router, tags=["agent"])
35
+ router.include_router(scheduler.router, tags=["scheduler"])
36
+ router.include_router(bot.router, tags=["bot"])
37
+ router.include_router(push.router, tags=["push"])
38
+ router.include_router(reports.router, tags=["reports"])
39
+ router.include_router(trade.router, tags=["trade"])
40
+ router.include_router(calendar.router, tags=["calendar"])
41
+ router.include_router(macro.router, tags=["macro"])
42
+ router.include_router(thesis.router, tags=["thesis"])
43
+ router.include_router(updates.router, tags=["updates"])
@@ -0,0 +1,14 @@
1
+ """Platform-agnostic chat-bot command layer.
2
+
3
+ An IncomingMessage (from any platform: Telegram today, a plain HTTP
4
+ test endpoint, or a future Discord/Slack listener) goes through
5
+ CommandDispatcher, which routes it to a registered Command and returns
6
+ a CommandResult - the same text response regardless of where the
7
+ message came from.
8
+
9
+ Only one real inbound listener exists so far: marketmyna/bot/telegram_listener.py
10
+ (long-polling). The 13 other outbound-only notifier channels in
11
+ marketmyna/notifiers/ do NOT have an inbound counterpart yet - that's real,
12
+ separate follow-up work per platform, not silently implied by this
13
+ package's existence.
14
+ """
File without changes
@@ -0,0 +1,40 @@
1
+ """/analyze SYMBOL [market] [mode] - runs the full agent pipeline and
2
+ replies with a short readable summary."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ...agent.dashboard import build_dashboard
7
+ from ...agent.orchestrator import AgentOrchestrator
8
+ from ..formatting import format_dashboard
9
+ from ..models import CommandResult, IncomingMessage
10
+ from .base import Command
11
+
12
+
13
+ class AnalyzeCommand(Command):
14
+ name = "analyze"
15
+ aliases = ["a"]
16
+ usage = "/analyze SYMBOL [market] [mode]"
17
+ description = "Full multi-agent AI analysis of one stock (technical, risk, news, decision)."
18
+ group = "AI analysis"
19
+ example = "/analyze AAPL us standard"
20
+ help_text = "/analyze SYMBOL [market] [mode] - run the agent pipeline on one symbol (default market=us, mode=standard)"
21
+
22
+ def __init__(self, config):
23
+ self._config = config
24
+
25
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
26
+ parts = args.split()
27
+ if not parts:
28
+ return CommandResult(text=f"Usage: {self.help_text}", success=False)
29
+
30
+ symbol = parts[0]
31
+ market = parts[1] if len(parts) > 1 else "us"
32
+ mode = parts[2] if len(parts) > 2 else "standard"
33
+
34
+ orchestrator = AgentOrchestrator(config=self._config)
35
+ try:
36
+ ctx = orchestrator.run(symbol, market, mode=mode)
37
+ except Exception as exc:
38
+ return CommandResult(text=f"Analysis failed for {symbol}: {exc}", success=False)
39
+
40
+ return CommandResult(text=format_dashboard(build_dashboard(ctx)))