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,323 @@
1
+ """Stock research from chat: the same services the Analyze page shows
2
+ (quote, overview, valuation, financials, estimates, dividends, peers,
3
+ Shariah screen, compare), as short text replies.
4
+
5
+ Every command takes SYMBOL [market]; "us:AAPL" works too. Without a
6
+ market the symbol is looked up in the listings (see common.guess_market).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ...utils.deadline import call_with_deadline
12
+ from ..models import CommandResult, IncomingMessage
13
+ from .base import Command
14
+ from .common import big, num, parse_symbol, pct
15
+
16
+ GROUP = "Stock research"
17
+
18
+
19
+ class _SymbolCommand(Command):
20
+ group = GROUP
21
+
22
+ def __init__(self, config):
23
+ self._config = config
24
+
25
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
26
+ symbol, market, rest = parse_symbol(args, self._config)
27
+ if not symbol:
28
+ return CommandResult(text=f"Usage: {self.usage}\nExample: {self.example}", success=False)
29
+ try:
30
+ return CommandResult(text=self.run(symbol, market, rest))
31
+ except TimeoutError:
32
+ return CommandResult(text=f"{symbol}: the data source took too long. Try again.", success=False)
33
+ except Exception as exc: # noqa: BLE001 - one bad source must not crash the chat
34
+ return CommandResult(text=f"{symbol} ({market}): {exc}", success=False)
35
+
36
+ def run(self, symbol: str, market: str, rest: list[str]) -> str:
37
+ raise NotImplementedError
38
+
39
+
40
+ class QuoteCommand(_SymbolCommand):
41
+ name = "quote"
42
+ aliases = ["q", "price"]
43
+ usage = "/quote SYMBOL [market]"
44
+ description = "Live price, day change, day range, volume and market cap."
45
+ example = "/quote RELIANCE india"
46
+
47
+ def run(self, symbol, market, rest):
48
+ from ...core.constants import Markets
49
+ from ...data.china import ChinaAFetcher, HongKongFetcher
50
+ from ...data.crypto import CryptoDataFetcher
51
+ from ...data.india import IndiaDataFetcher
52
+ from ...data.stocks import StockDataFetcher
53
+
54
+ fetcher = {"india": IndiaDataFetcher, "crypto": CryptoDataFetcher, "china_a": ChinaAFetcher,
55
+ "hong_kong": HongKongFetcher}.get(Markets(market).value, StockDataFetcher)()
56
+ q = call_with_deadline(fetcher.get_quote, 20, symbol) or {}
57
+ # Exchange quotes can come back thin (e.g. NSE off-hours); fill gaps from the listings row.
58
+ try:
59
+ from ...data.listings import get_store
60
+
61
+ found = get_store().lookup([(market, kind, symbol) for kind in ("stock", "etf")])
62
+ row = next(iter(found.values()), {})
63
+ except Exception:
64
+ row = {}
65
+ for key, alt in (("price", "price"), ("open", "open"), ("day_high", "high"), ("day_low", "low"),
66
+ ("volume", "volume"), ("market_cap", "market_cap"), ("change_percent", "change_percent")):
67
+ if not q.get(key) and row.get(alt):
68
+ q[key] = row[alt]
69
+ if q.get("price") is None:
70
+ return f"No quote for {symbol} ({market})."
71
+ def pair(*items):
72
+ return " · ".join(f"{label} {fmt(q[key])}" for label, key, fmt in items if q.get(key))
73
+
74
+ lines = [f"{symbol} ({market}) {num(q['price'])} {num(q.get('change'))} ({pct(q.get('change_percent'), 2)})",
75
+ pair(("Open", "open", num), ("Prev close", "previous_close", num)),
76
+ f"Day range {num(q['day_low'])} - {num(q['day_high'])}" if q.get("day_low") and q.get("day_high") else "",
77
+ pair(("Volume", "volume", big), ("Market cap", "market_cap", big))]
78
+ return "\n".join(line for line in lines if line)
79
+
80
+
81
+ class OverviewCommand(_SymbolCommand):
82
+ name = "overview"
83
+ aliases = ["ov", "tech"]
84
+ usage = "/overview SYMBOL [market]"
85
+ description = "Technicals, trend, returns, risk, fundamentals, analyst consensus and upcoming events."
86
+ example = "/overview AAPL us"
87
+
88
+ def run(self, symbol, market, rest):
89
+ from ...services.overview import build_overview
90
+
91
+ d = call_with_deadline(build_overview, 60, symbol, market)
92
+ t, r, f, a, e = (d.get(k) or {} for k in ("technical", "risk", "fundamentals", "analysts", "events"))
93
+ lines = [f"{symbol} ({market}) overview"]
94
+ if t:
95
+ counts = t.get("counts") # absent on a public deploy (no per-stock verdicts)
96
+ lines.append(f"Trend: {t.get('trend', 'n/a')}" + (
97
+ f" · indicators {counts.get('buy', 0)} up / {counts.get('sell', 0)} down / "
98
+ f"{counts.get('neutral', 0)} neutral" if counts else ""))
99
+ ret = t.get("returns") or {}
100
+ lines.append("Returns: " + " · ".join(f"{k} {pct(v)}" for k, v in list(ret.items())[:6]))
101
+ lines.append(f"52w range {num(t.get('low_52w'))} - {num(t.get('high_52w'))} "
102
+ f"({pct(t.get('from_high_pct'))} from high)")
103
+ rsi = next((i for i in t.get("indicators") or [] if i.get("key") == "rsi"), None)
104
+ if rsi:
105
+ lines.append(f"RSI(14) {num(rsi.get('value'), 1)}")
106
+ if r:
107
+ lines.append(f"Risk: {r.get('level', 'n/a')} · volatility {pct(r.get('volatility_pct'), 1, False)} · "
108
+ f"max drawdown {pct(r.get('max_drawdown_pct'))} · beta {num(r.get('beta'))}")
109
+ if f:
110
+ lines.append(f"Fundamentals: score {f.get('score', 'n/a')}/100 · health {f.get('health_rating', 'n/a')}")
111
+ if a.get("count"):
112
+ # Third-party consensus as a direction (build_overview), without the price targets.
113
+ lines.append(f"Broker consensus ({a['count']} analysts): {a.get('recommendation') or 'n/a'}")
114
+ ev = [f"earnings {e['earnings_date']}" if e.get("earnings_date") else "",
115
+ f"ex-dividend {e['ex_dividend_date']}" if e.get("ex_dividend_date") else ""]
116
+ if any(ev):
117
+ lines.append("Events: " + " · ".join(x for x in ev if x))
118
+ return "\n".join(lines)
119
+
120
+
121
+ class ValuationCommand(_SymbolCommand):
122
+ name = "valuation"
123
+ aliases = ["val"]
124
+ usage = "/valuation SYMBOL [market]"
125
+ description = "Valuation meter: P/E, P/B and other multiples against the sector median, plus the growth-at-a-price check."
126
+ example = "/valuation INFY india"
127
+
128
+ def run(self, symbol, market, rest):
129
+ from ...services.valuation import meter
130
+
131
+ d = call_with_deadline(meter, 90, symbol, market)
132
+ if d.get("status") != "ok":
133
+ return f"{symbol}: {d.get('reason') or 'no valuation data'}"
134
+ zone = {"below": "below its sector", "above": "above its sector", "in_line": "in line with its sector"}
135
+ lines = [f"{symbol} ({market}) valuation vs {d.get('sector')} ({d.get('peers')} peers): "
136
+ f"priced {zone.get(d.get('zone'), d.get('zone'))}"]
137
+ for m in d.get("multiples") or []:
138
+ if m.get("value") is not None:
139
+ lines.append(f"• {m['label']} {num(m['value'])} vs sector median {num(m.get('sector_median'))}")
140
+ g = d.get("garp") or {}
141
+ if g.get("total"):
142
+ lines.append(f"Growth at a reasonable price: {g['passed']}/{g['total']} checks pass (PEG {num(g.get('peg'))})")
143
+ return "\n".join(lines)
144
+
145
+
146
+ class FinancialsCommand(_SymbolCommand):
147
+ name = "financials"
148
+ aliases = ["fin"]
149
+ usage = "/financials SYMBOL [market] [annual|quarterly]"
150
+ description = "Income statement, balance sheet and cash flow health with the key findings."
151
+ example = "/financials TCS india"
152
+
153
+ def run(self, symbol, market, rest):
154
+ from ...services.financials import build_financials
155
+
156
+ period = "quarterly" if rest and rest[0].lower().startswith("q") else "annual"
157
+ d = call_with_deadline(build_financials, 60, symbol, market, period)
158
+ o = d.get("overall") or {}
159
+ lines = [f"{d.get('name') or symbol} ({market}) {period}: {o.get('label', 'n/a')} ({o.get('score', 'n/a')}/100)"]
160
+ for key, title in (("income", "Income statement"), ("balance", "Balance sheet"), ("cashflow", "Cash flow")):
161
+ part = d.get(key) or {}
162
+ v = part.get("verdict") or {}
163
+ if not v:
164
+ continue
165
+ lines.append(f"{title}: {v.get('label')} ({v.get('score')}) as of {part.get('as_of')}")
166
+ for f in (part.get("findings") or [])[:3]:
167
+ lines.append(f" {'+' if f.get('tone') == 'good' else '-' if f.get('tone') == 'bad' else '·'} {f['text']}")
168
+ return "\n".join(lines)
169
+
170
+
171
+ class EstimatesCommand(_SymbolCommand):
172
+ name = "estimates"
173
+ aliases = ["est"]
174
+ usage = "/estimates SYMBOL [market]"
175
+ description = "Analyst EPS and revenue consensus, revisions and recent beats or misses."
176
+ example = "/estimates MSFT us"
177
+
178
+ def run(self, symbol, market, rest):
179
+ from ...services.analysis_run import yahoo_symbol
180
+ from ...services.estimates import build
181
+
182
+ if market == "crypto":
183
+ return "No analyst estimates for crypto."
184
+ d = call_with_deadline(build, 45, yahoo_symbol(symbol, market))
185
+ if not d.get("available"):
186
+ return f"No analyst estimates for {symbol}."
187
+ cur = d.get("currency") or ""
188
+ lines = [f"{symbol} analyst estimates ({cur})"]
189
+ for p in d.get("periods") or []:
190
+ eps, rev, r = p.get("eps") or {}, p.get("revenue") or {}, p.get("revisions") or {}
191
+ growth = eps.get("growth")
192
+ lines.append(f"• {p['label']}: EPS {num(eps.get('avg'))}"
193
+ + (f" ({pct(growth * 100 if abs(growth) < 5 else growth)} y/y)" if growth is not None else "")
194
+ + (f", revenue {big(rev.get('avg'))}" if rev.get("avg") else "")
195
+ + (f", 30d revisions {int(r.get('up_30d') or 0)} up / {int(r.get('down_30d') or 0)} down" if r else ""))
196
+ m, b = d.get("momentum") or {}, d.get("beats") or {}
197
+ if m.get("label"):
198
+ lines.append(f"Estimate trend (90d): {m['label']}")
199
+ if b.get("total"):
200
+ lines.append(f"Beat estimates in {b['beat']} of the last {b['total']} quarters")
201
+ return "\n".join(lines)
202
+
203
+
204
+ class DividendsCommand(_SymbolCommand):
205
+ name = "dividends"
206
+ aliases = ["div"]
207
+ usage = "/dividends SYMBOL [market]"
208
+ description = "Dividend yield, payout ratio, growth, years without a cut and the last payment."
209
+ example = "/dividends ITC india"
210
+
211
+ def run(self, symbol, market, rest):
212
+ from ...services.analysis_run import yahoo_symbol
213
+ from ...services.dividends import build
214
+
215
+ if market == "crypto":
216
+ return "Crypto doesn't pay dividends."
217
+ d = call_with_deadline(build, 45, yahoo_symbol(symbol, market))
218
+ if not d.get("pays"):
219
+ return f"{symbol} has no dividend history."
220
+ last = d.get("last") or {}
221
+ return "\n".join([
222
+ f"{symbol} dividends ({d.get('currency')}) · {d.get('frequency') or 'irregular'}",
223
+ f"Trailing yield {pct(d.get('ttm_yield'), 2, False)} ({num(d.get('ttm'))} in 12 months) · "
224
+ f"forward {pct(d.get('forward_yield'), 2, False)}",
225
+ f"Payout ratio {pct(d.get('payout_ratio'), 0, False)} · 5y growth {pct(d.get('growth_5y'))} · "
226
+ f"{d.get('years_without_cut', 'n/a')} year{'' if d.get('years_without_cut') == 1 else 's'} without a cut",
227
+ f"Last paid {num(last.get('amount'))} on {last.get('date', 'n/a')}"
228
+ + (f" · next ex-date {d['next_ex_date']}" if d.get("next_ex_date") else ""),
229
+ ])
230
+
231
+
232
+ class PeersCommand(_SymbolCommand):
233
+ name = "peers"
234
+ aliases = []
235
+ usage = "/peers SYMBOL [market]"
236
+ description = "Closest companies in the same sector by size, with P/E and day change."
237
+ example = "/peers HDFCBANK india"
238
+
239
+ def run(self, symbol, market, rest):
240
+ from ...services.compare import peers
241
+
242
+ d = call_with_deadline(peers, 60, symbol, market, 6)
243
+ rows = d.get("peers") or []
244
+ if not rows:
245
+ return f"No peers found for {symbol}."
246
+ lines = [f"{symbol} peers · {d.get('basis')}"]
247
+ for p in rows:
248
+ lines.append(f"• {p['symbol']} cap {big(p.get('market_cap'))} · P/E {num(p.get('pe'), 1)} · "
249
+ f"day {pct(p.get('change_percent'))}")
250
+ lines.append(f"Compare: /compare {symbol}," + ",".join(p["symbol"] for p in rows[:3]) + f" {market}")
251
+ return "\n".join(lines)
252
+
253
+
254
+ class ShariahCommand(_SymbolCommand):
255
+ name = "ethical"
256
+ aliases = ["shariah", "halal"] # the old names keep working
257
+ usage = "/ethical SYMBOL [market]"
258
+ description = "Ethical screen: business activity and the debt, cash and interest-income ratios."
259
+ example = "/ethical AAPL us"
260
+
261
+ def run(self, symbol, market, rest):
262
+ from ...services.shariah import build_shariah_profile
263
+
264
+ d = call_with_deadline(build_shariah_profile, 60, symbol, market)
265
+ lines = [f"{d.get('name') or symbol}: {d.get('verdict_label') or d.get('verdict') or 'no verdict'}"]
266
+ b = d.get("business") or {}
267
+ if b.get("reason"):
268
+ lines.append(f"Business: {b['reason']}")
269
+ for c in d.get("checks") or []:
270
+ v = "n/a" if c.get("value") is None else f"{c['value']:.1f}%"
271
+ lines.append(f"• {c['label']}: {v} (limit {c.get('limit')}%) {c.get('status', '')}")
272
+ lines.append("A screening aid, not a fatwa.")
273
+ return "\n".join(lines)
274
+
275
+
276
+ class CompareCommand(Command):
277
+ name = "compare"
278
+ aliases = ["vs"]
279
+ usage = "/compare SYM1,SYM2[,SYM3] [market] [6mo|1y|3y|5y]"
280
+ description = "2-5 stocks side by side: returns, volatility, drawdown, beta and correlation."
281
+ example = "/compare TCS,INFY,WIPRO india 1y"
282
+ group = GROUP
283
+
284
+ def __init__(self, config):
285
+ self._config = config
286
+
287
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
288
+ from ...services.compare import compare
289
+ from .common import guess_market, market_word
290
+
291
+ parts = args.split()
292
+ if not parts:
293
+ return CommandResult(text=f"Usage: {self.usage}\nExample: {self.example}", success=False)
294
+ market = next((market_word(p) for p in parts[1:] if market_word(p)), None)
295
+ period = next((p.lower() for p in parts[1:] if p.lower() in ("6mo", "1y", "3y", "5y")), "1y")
296
+ items = []
297
+ for tok in parts[0].split(","):
298
+ if not tok:
299
+ continue
300
+ m, _, s = tok.partition(":") if ":" in tok else ("", "", tok)
301
+ sym = s.upper()
302
+ items.append((sym, market_word(m) or market or guess_market(sym, self._config)))
303
+ if not 2 <= len(items) <= 5:
304
+ return CommandResult(text="Give 2 to 5 symbols, comma separated.", success=False)
305
+ try:
306
+ d = call_with_deadline(compare, 60, items, period)
307
+ except (ValueError, TimeoutError) as exc:
308
+ return CommandResult(text=str(exc), success=False)
309
+ syms = [i["symbol"] for i in d.get("items") or []]
310
+ lines = [f"Compare {', '.join(syms)} ({period})"]
311
+ for row in d.get("returns") or []:
312
+ lines.append(f"{row['window']}: " + " · ".join(f"{s} {pct(row['values'].get(s))}" for s in syms))
313
+ risk = d.get("risk") or {}
314
+ for s in syms:
315
+ r = risk.get(s) or {}
316
+ lines.append(f"{s}: volatility {pct(r.get('volatility'), 1, False)} · max drawdown {pct(r.get('max_drawdown'))} · "
317
+ f"beta {num(r.get('beta'))}")
318
+ corr = d.get("correlation") or {}
319
+ cs, mx = corr.get("symbols") or [], corr.get("matrix") or []
320
+ pairs = [f"{cs[i]}/{cs[j]} {mx[i][j]:.2f}" for i in range(len(cs)) for j in range(i + 1, len(cs)) if mx[i][j] is not None]
321
+ if pairs:
322
+ lines.append("Correlation: " + " · ".join(pairs[:6]))
323
+ return CommandResult(text="\n".join(lines))
@@ -0,0 +1,48 @@
1
+ """/status - reports the persisted schedule config plus whether the
2
+ scheduler thread is currently running.
3
+
4
+ Unlike an HTTP endpoint, a bot command has no request.app.state to pull
5
+ the live RuntimeSchedulerService from - `scheduler_running` is an
6
+ optional callable injected by whoever wires the bot into the app (see
7
+ marketmyna/api/app.py's lifespan), so this command never reaches into global
8
+ state itself.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Callable, Optional
14
+
15
+ from ...services.schedule_config import ScheduleConfigStore
16
+ from ..models import CommandResult, IncomingMessage
17
+ from .base import Command
18
+
19
+
20
+ class StatusCommand(Command):
21
+ name = "status"
22
+ aliases = ["s"]
23
+ usage = "/status"
24
+ description = "Scheduled-report settings and whether the scheduler is running."
25
+ group = "System"
26
+ example = "/status"
27
+ help_text = "/status - show the scheduled-analysis config and whether it's running"
28
+
29
+ def __init__(self, config, scheduler_running: Optional[Callable[[], bool]] = None):
30
+ self._config = config
31
+ self._scheduler_running = scheduler_running
32
+
33
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
34
+ cfg = ScheduleConfigStore(config=self._config).load()
35
+
36
+ running = self._scheduler_running() if self._scheduler_running else None
37
+ lines = [f"Scheduler thread: {'running' if running else 'not running' if running is not None else 'unknown'}"]
38
+ lines.append(f"Scheduled analysis: {'enabled' if cfg.enabled else 'disabled'}")
39
+ if cfg.symbols:
40
+ lines.append(f"Symbols: {', '.join(cfg.symbols)} ({cfg.market}, {cfg.mode})")
41
+ if cfg.interval_minutes:
42
+ lines.append(f"Every {cfg.interval_minutes} minutes")
43
+ elif cfg.time_of_day:
44
+ lines.append(f"Daily at {cfg.time_of_day}")
45
+ if cfg.last_run_at:
46
+ lines.append(f"Last run: {cfg.last_run_at.isoformat()} ({cfg.last_run_status})")
47
+
48
+ return CommandResult(text="\n".join(lines))
@@ -0,0 +1,176 @@
1
+ """Trade section from chat: /alert, /alerts, /risk, /scan.
2
+
3
+ Symbols take an optional market prefix like the scheduler's STOCK_LIST
4
+ (us:AAPL, crypto:BTC-USD); without one, the Risk tab's market is used."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from ...storage import DatabaseManager, PriceAlertRecord, SavedScanRecord, get_db_url
9
+ from ..models import CommandResult, IncomingMessage
10
+ from .base import Command
11
+
12
+ MARKETS = {"us", "india", "china_a", "hong_kong", "crypto", "global"}
13
+ KIND_WORDS = {
14
+ "above": "price_above", ">": "price_above", ">=": "price_above",
15
+ "below": "price_below", "<": "price_below", "<=": "price_below",
16
+ "up": "pct_up", "down": "pct_down",
17
+ "rsi>": "rsi_above", "rsi<": "rsi_below",
18
+ "volume": "volume_spike", "vol": "volume_spike",
19
+ "breakout": "high_breakout", "breakdown": "low_breakdown",
20
+ }
21
+
22
+
23
+ def _db(config):
24
+ return DatabaseManager.instance(get_db_url(config=config))
25
+
26
+
27
+ def _default_market(config) -> str:
28
+ from ...trading.risk import load_settings
29
+
30
+ return load_settings(config).get("market") or "india"
31
+
32
+
33
+ def _symbol(token: str, config) -> tuple[str, str]:
34
+ if ":" in token:
35
+ market, sym = token.split(":", 1)
36
+ if market.lower() in MARKETS:
37
+ return sym.upper(), market.lower()
38
+ return token.upper(), _default_market(config)
39
+
40
+
41
+ class AlertCommand(Command):
42
+ name = "alert"
43
+ aliases = ["al"]
44
+ usage = "/alert SYMBOL above|below PRICE [note]"
45
+ description = "Create a price alert (also up|down %, rsi>|rsi< N, volume X, breakout|breakdown DAYS)."
46
+ group = "Trading"
47
+ example = "/alert india:RELIANCE below 1210"
48
+ help_text = (
49
+ "/alert SYMBOL above|below PRICE [note] - price alert; also up|down PCT, rsi>|rsi< N, "
50
+ "volume X, breakout|breakdown DAYS. Prefix the market: us:AAPL, crypto:BTC-USD"
51
+ )
52
+
53
+ def __init__(self, config):
54
+ self._config = config
55
+
56
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
57
+ from ...trading.alerts import describe
58
+
59
+ parts = args.split()
60
+ if len(parts) < 3:
61
+ return CommandResult(text=f"Usage: {self.help_text}", success=False)
62
+ symbol, market = _symbol(parts[0], self._config)
63
+ kind = KIND_WORDS.get(parts[1].lower())
64
+ if kind is None:
65
+ return CommandResult(text=f"Unknown condition '{parts[1]}'. Use one of: {', '.join(sorted(KIND_WORDS))}", success=False)
66
+ try:
67
+ value = float(parts[2].rstrip("%x"))
68
+ except ValueError:
69
+ return CommandResult(text=f"'{parts[2]}' is not a number", success=False)
70
+ note = " ".join(parts[3:])
71
+ with _db(self._config).get_session() as s:
72
+ rec = PriceAlertRecord(symbol=symbol, market=market, kind=kind, value=value, note=note, status="active", armed=True)
73
+ s.add(rec)
74
+ s.flush()
75
+ alert_id = rec.id
76
+ return CommandResult(text=f"🔔 Alert #{alert_id} set: {symbol} ({market}) {describe(kind, value)}")
77
+
78
+
79
+ class AlertsCommand(Command):
80
+ name = "alerts"
81
+ aliases = []
82
+ usage = "/alerts [delete|pause|resume ID]"
83
+ description = "List your active alerts, or delete / pause / resume one."
84
+ group = "Trading"
85
+ example = "/alerts"
86
+ help_text = "/alerts - list active alerts · /alerts delete ID · /alerts pause ID · /alerts resume ID"
87
+
88
+ def __init__(self, config):
89
+ self._config = config
90
+
91
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
92
+ from ...trading.alerts import describe
93
+
94
+ parts = args.split()
95
+ if len(parts) == 2 and parts[0] in ("delete", "pause", "resume") and parts[1].lstrip("#").isdigit():
96
+ alert_id = int(parts[1].lstrip("#"))
97
+ with _db(self._config).get_session() as s:
98
+ rec = s.get(PriceAlertRecord, alert_id)
99
+ if rec is None:
100
+ return CommandResult(text=f"No alert #{alert_id}", success=False)
101
+ if parts[0] == "delete":
102
+ s.delete(rec)
103
+ else:
104
+ rec.status = "paused" if parts[0] == "pause" else "active"
105
+ rec.armed = True
106
+ return CommandResult(text=f"Alert #{alert_id} {parts[0]}d")
107
+ with _db(self._config).get_session() as s:
108
+ recs = s.query(PriceAlertRecord).filter(PriceAlertRecord.status.in_(["active", "paused"])).order_by(PriceAlertRecord.id).all()
109
+ if not recs:
110
+ return CommandResult(text="No active alerts. Create one with /alert RELIANCE below 1210")
111
+ lines = [f"#{r.id} {r.symbol} ({r.market}): {describe(r.kind, r.value)}"
112
+ + (" [paused]" if r.status == "paused" else "") + (f" · {r.note}" if r.note else "") for r in recs]
113
+ return CommandResult(text="\n".join(lines))
114
+
115
+
116
+ class RiskCommand(Command):
117
+ name = "risk"
118
+ aliases = []
119
+ usage = "/risk"
120
+ description = "Today's P&L against your daily loss limit, open risk and trade count."
121
+ group = "Trading"
122
+ example = "/risk"
123
+ help_text = "/risk - today's P&L against your daily loss limit, open risk and trade count"
124
+
125
+ def __init__(self, config):
126
+ self._config = config
127
+
128
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
129
+ from ...trading.risk import status
130
+
131
+ r = status(self._config)
132
+ icon = {"ok": "✅", "warn": "⚠️", "stop": "🛑"}[r["status"]]
133
+ lines = [
134
+ f"{icon} Risk: {r['status'].upper()} ({r['settings']['market']})",
135
+ f"Net P&L today: {r['day_pnl']:,.2f} (realised {r['realized']:,.2f}, open {r['unrealized']:,.2f})",
136
+ f"Loss limit: {r['loss_limit']:,.2f}" + (f" · {r['limit_used_pct']}% used" if r["limit_used_pct"] else ""),
137
+ f"Open risk to stops: {r['open_risk']:,.2f} ({r['open_risk_pct']}% of capital)",
138
+ f"Trades today: {r['trades_today']} / {r['settings']['max_trades_per_day']}",
139
+ ]
140
+ lines += [f"• {x}" for x in r["reasons"]]
141
+ return CommandResult(text="\n".join(lines))
142
+
143
+
144
+ class ScanCommand(Command):
145
+ name = "scan"
146
+ aliases = []
147
+ usage = "/scan [NAME|ID]"
148
+ description = "List saved scans, or run one now."
149
+ group = "Trading"
150
+ example = "/scan"
151
+ help_text = "/scan - list saved scans · /scan NAME or ID - run one now"
152
+
153
+ def __init__(self, config):
154
+ self._config = config
155
+
156
+ def handle(self, message: IncomingMessage, args: str) -> CommandResult:
157
+ from ...trading.scanner import run_saved_scans
158
+
159
+ with _db(self._config).get_session() as s:
160
+ saved = [(r.id, r.name) for r in s.query(SavedScanRecord).order_by(SavedScanRecord.id)]
161
+ if not saved:
162
+ return CommandResult(text="No saved scans. Save one in Trade → Scanner.")
163
+ key = args.strip().lstrip("#").lower()
164
+ if not key:
165
+ return CommandResult(text="\n".join(f"#{i} {n}" for i, n in saved) + "\nRun one with /scan NAME")
166
+ match = next((i for i, n in saved if str(i) == key or n.lower() == key), None) or next(
167
+ (i for i, n in saved if key in n.lower()), None)
168
+ if match is None:
169
+ return CommandResult(text=f"No saved scan matches '{args.strip()}'", success=False)
170
+ out = run_saved_scans(self._config, force_id=match)[0]
171
+ if not out["rows"]:
172
+ return CommandResult(text=f"🔎 {out['name']}: no matches in {out['scanned']} stocks")
173
+ lines = [f"🔎 {out['name']}: {len(out['rows'])} matches in {out['scanned']} stocks"]
174
+ for r in out["rows"][:20]:
175
+ lines.append(f"• {r['symbol']} {r['price']:,.2f} ({r['change_percent']:+.1f}%): {', '.join(r['reasons'][:2])}")
176
+ return CommandResult(text="\n".join(lines))
@@ -0,0 +1,95 @@
1
+ """Routes an IncomingMessage to a registered Command.
2
+
3
+ Parsing is forgiving: "/analyze AAPL" is the normal form, but a bare
4
+ "analyze AAPL" (first word matching a registered command name/alias,
5
+ no leading slash) also routes correctly. Anything else with no
6
+ recognized command word falls through to the "ask" command if one is
7
+ registered (so a plain question like "should I buy AAPL?" gets a
8
+ useful answer instead of an "unknown command" reply) - the same
9
+ graceful-fallback shape as daily_stock_analysis's own dispatcher, kept
10
+ much smaller here (no rate limiting, no admin users, no NL-intent LLM
11
+ routing - none of that is in scope for this phase).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ from .commands.base import Command
19
+ from .models import CommandResult, IncomingMessage
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class CommandDispatcher:
25
+ def __init__(self, command_prefix: str = "/"):
26
+ self.command_prefix = command_prefix
27
+ self._commands: dict[str, Command] = {}
28
+ self._aliases: dict[str, str] = {}
29
+
30
+ def register(self, command: Command) -> None:
31
+ name = command.name.lower()
32
+ if name in self._commands:
33
+ logger.warning("Command '%s' already registered, overwriting", name)
34
+ self._commands[name] = command
35
+ for alias in command.aliases:
36
+ self._aliases[alias.lower()] = name
37
+
38
+ def get_command(self, name: str) -> "Command | None":
39
+ name = name.lower()
40
+ if name in self._commands:
41
+ return self._commands[name]
42
+ if name in self._aliases:
43
+ return self._commands.get(self._aliases[name])
44
+ return None
45
+
46
+ def list_commands(self) -> list[Command]:
47
+ return sorted(self._commands.values(), key=lambda c: c.name)
48
+
49
+ # Reference order of the groups; anything else goes after, then "System".
50
+ GROUP_ORDER = ("Stock research", "AI analysis", "Markets", "Portfolio", "Trading")
51
+
52
+ def groups(self) -> dict[str, list[Command]]:
53
+ """Commands by group, in registration order within a group."""
54
+ out: dict[str, list[Command]] = {}
55
+ for command in self._commands.values():
56
+ out.setdefault(command.group, []).append(command)
57
+ rank = {g: i for i, g in enumerate(self.GROUP_ORDER)}
58
+ return dict(sorted(out.items(), key=lambda kv: (kv[0] == "System", rank.get(kv[0], len(rank)))))
59
+
60
+ def _split(self, text: str) -> tuple[str, str]:
61
+ stripped = text.strip()
62
+ if not stripped:
63
+ return "", ""
64
+ parts = stripped.split(maxsplit=1)
65
+ return parts[0], (parts[1] if len(parts) > 1 else "")
66
+
67
+ def dispatch(self, message: IncomingMessage) -> CommandResult:
68
+ text = (message.text or "").strip()
69
+ if not text:
70
+ return CommandResult(text="", success=True)
71
+
72
+ if text.startswith(self.command_prefix):
73
+ head, args = self._split(text[len(self.command_prefix) :])
74
+ else:
75
+ head, args = self._split(text)
76
+
77
+ command = self.get_command(head) if head else None
78
+
79
+ if command is None:
80
+ # No recognized command word - if the message started with the
81
+ # prefix, that's a genuine unknown-command; otherwise treat the
82
+ # whole message as a free-form question for "ask", if present.
83
+ if text.startswith(self.command_prefix) or "ask" not in self._commands:
84
+ return CommandResult(
85
+ text=f"Unknown command. Try {self.command_prefix}help for a list of commands.",
86
+ success=False,
87
+ )
88
+ command = self._commands["ask"]
89
+ args = text
90
+
91
+ try:
92
+ return command.handle(message, args)
93
+ except Exception as exc: # noqa: BLE001 - a command must never crash the listener
94
+ logger.exception("Command '%s' failed", command.name)
95
+ return CommandResult(text=f"Command failed: {exc}", success=False)
@@ -0,0 +1,25 @@
1
+ """Turns a dashboard dict (marketmyna/agent/dashboard.py's build_dashboard output)
2
+ into a short human-readable chat message - not a raw JSON dump."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ def format_dashboard(dashboard: dict) -> str:
8
+ outlook = dashboard.get("outlook")
9
+ lines = [f"{dashboard['symbol']} ({dashboard['market']}) - {f'{outlook} outlook' if outlook else 'AI analysis'}"]
10
+
11
+ summary = dashboard.get("analysis_summary")
12
+ if summary:
13
+ lines.append(summary)
14
+
15
+ target = dashboard.get("target_price")
16
+ stop = dashboard.get("stop_loss")
17
+ if target is not None or stop is not None:
18
+ lines.append(f"Target: {target if target is not None else 'n/a'} Stop: {stop if stop is not None else 'n/a'}")
19
+
20
+ if dashboard.get("technical_signal"):
21
+ lines.append(f"Technical: {dashboard['technical_signal']}")
22
+ if dashboard.get("risk_level"):
23
+ lines.append(f"Risk: {dashboard['risk_level']}")
24
+
25
+ return "\n".join(lines)