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,27 @@
1
+ """Platform-agnostic message shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class IncomingMessage:
11
+ """One message from any platform, normalized to the same shape
12
+ before it reaches CommandDispatcher."""
13
+
14
+ platform: str
15
+ chat_id: str
16
+ text: str
17
+ user_id: Optional[str] = None
18
+ raw: Any = None
19
+
20
+
21
+ @dataclass
22
+ class CommandResult:
23
+ """A command's response - plain text, no platform-specific
24
+ formatting (markdown/embeds/etc are each listener's own concern)."""
25
+
26
+ text: str
27
+ success: bool = True
@@ -0,0 +1,50 @@
1
+ """Builds a CommandDispatcher with every command registered - the one
2
+ place that ordering (HelpCommand last, see commands/help.py) is handled,
3
+ so callers (marketmyna/api/app.py's lifespan, tests, a future CLI) don't each have
4
+ to get it right themselves."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Callable, Optional
9
+
10
+ from .commands.analyze import AnalyzeCommand
11
+ from .commands.ask import AskCommand
12
+ from .commands.batch import BatchCommand
13
+ from .commands.help import HelpCommand
14
+ from .commands.markets import CalendarCommand, MarketCommand, ScreenCommand, SectorsCommand
15
+ from .commands.portfolio import PortfolioCommand, ReviewCommand, WatchlistCommand
16
+ from .commands.research import (
17
+ CompareCommand,
18
+ DividendsCommand,
19
+ EstimatesCommand,
20
+ FinancialsCommand,
21
+ OverviewCommand,
22
+ PeersCommand,
23
+ QuoteCommand,
24
+ ShariahCommand,
25
+ ValuationCommand,
26
+ )
27
+ from .commands.status import StatusCommand
28
+ from .commands.trade import AlertCommand, AlertsCommand, RiskCommand, ScanCommand
29
+ from .dispatcher import CommandDispatcher
30
+
31
+
32
+ def build_default_dispatcher(config, scheduler_running: Optional[Callable[[], bool]] = None) -> CommandDispatcher:
33
+ dispatcher = CommandDispatcher()
34
+ for research in (QuoteCommand, OverviewCommand, ValuationCommand, FinancialsCommand, EstimatesCommand,
35
+ DividendsCommand, PeersCommand, ShariahCommand, CompareCommand):
36
+ dispatcher.register(research(config))
37
+ for market in (MarketCommand, SectorsCommand, ScreenCommand, CalendarCommand):
38
+ dispatcher.register(market(config))
39
+ for own in (PortfolioCommand, ReviewCommand, WatchlistCommand):
40
+ dispatcher.register(own(config))
41
+ dispatcher.register(AnalyzeCommand(config))
42
+ dispatcher.register(BatchCommand(config))
43
+ dispatcher.register(AskCommand(config))
44
+ dispatcher.register(StatusCommand(config, scheduler_running=scheduler_running))
45
+ dispatcher.register(AlertCommand(config))
46
+ dispatcher.register(AlertsCommand(config))
47
+ dispatcher.register(RiskCommand(config))
48
+ dispatcher.register(ScanCommand(config))
49
+ dispatcher.register(HelpCommand(dispatcher))
50
+ return dispatcher
@@ -0,0 +1,227 @@
1
+ """Real (not stubbed) inbound Telegram listener: long-polls getUpdates,
2
+ routes each message through a CommandDispatcher, replies via Telegram's
3
+ sendMessage - the one platform with genuine two-way bot support in this
4
+ phase. The 13 other notifier channels in marketmyna/notifiers/ remain outbound
5
+ (alert-push) only; each would need its own listener (webhook or poll,
6
+ depending on the platform) as separate follow-up work.
7
+
8
+ Reuses config.telegram_bot_token (the same credential marketmyna/notifiers/
9
+ telegram.py's outbound TelegramSender uses) rather than a new field.
10
+ Does NOT reuse TelegramSender itself for replies: that sender's chat_id
11
+ is fixed at construction (one configured alert destination), but a bot
12
+ must reply to whichever chat_id sent the incoming message, which can
13
+ differ - so this calls Telegram's sendMessage directly with the
14
+ per-message chat_id instead of force-fitting the wrong abstraction.
15
+
16
+ Same start()/stop()/is_running() shape as RuntimeSchedulerService
17
+ (marketmyna/services/runtime_scheduler.py) for consistency across the app's
18
+ background services.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ from typing import Optional
26
+
27
+ import requests
28
+
29
+ from .dispatcher import CommandDispatcher
30
+ from .models import IncomingMessage
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ API_ROOT = "https://api.telegram.org"
35
+ POLL_TIMEOUT_SECONDS = 30
36
+ REQUEST_TIMEOUT_SECONDS = 35 # a bit more than the long-poll timeout itself
37
+ ERROR_BACKOFF_SECONDS = 5.0
38
+
39
+
40
+ IDLE_CHECK_SECONDS = 10.0 # how often to look for a newly saved token
41
+
42
+ SETUP_REPLY = (
43
+ "👋 This is the MarketMyna bot.\n\n"
44
+ "Your chat ID is {chat_id} - paste it into Settings → Notifications → Telegram → Chat ID."
45
+ )
46
+
47
+
48
+ def allowed_chats(config) -> set[str]:
49
+ """Chats the bot obeys: Settings → Telegram → Chat ID (comma-separated for
50
+ several, e.g. you and a group)."""
51
+ raw = str(getattr(config, "telegram_chat_id", None) or "")
52
+ return {c.strip() for c in raw.split(",") if c.strip()}
53
+
54
+
55
+ class TelegramListener:
56
+ """Long-polls getUpdates and answers commands - only from the chats in
57
+ Settings' Chat ID. Anyone else (and you, before a Chat ID is saved)
58
+ just gets told their chat ID, so the bot can't be used by strangers
59
+ who find its username.
60
+
61
+ Reads the token from the shared Config on every poll, so saving a
62
+ token in Settings takes effect without restarting the server."""
63
+
64
+ def __init__(self, config, dispatcher: CommandDispatcher):
65
+ self._config = config
66
+ self._dispatcher = dispatcher
67
+ self._last_update_id: Optional[int] = None
68
+ self._token_in_use: Optional[str] = None
69
+ self._stop_event = threading.Event()
70
+ self._thread: Optional[threading.Thread] = None
71
+
72
+ @property
73
+ def _bot_token(self) -> Optional[str]:
74
+ return getattr(self._config, "telegram_bot_token", None)
75
+
76
+ def is_configured(self) -> bool:
77
+ return bool(self._bot_token)
78
+
79
+ # ---- lifecycle ----
80
+
81
+ def start(self) -> None:
82
+ if self._thread is not None and self._thread.is_alive():
83
+ return
84
+ if not self.is_configured():
85
+ logger.info("TelegramListener waiting: no telegram_bot_token yet (picked up once saved in Settings)")
86
+ self._stop_event.clear()
87
+ self._thread = threading.Thread(target=self._loop, name="marketmyna-telegram-listener", daemon=True)
88
+ self._thread.start()
89
+
90
+ def stop(self, timeout: float = 5.0) -> None:
91
+ self._stop_event.set()
92
+ if self._thread is not None:
93
+ self._thread.join(timeout=timeout)
94
+
95
+ def is_running(self) -> bool:
96
+ return self._thread is not None and self._thread.is_alive()
97
+
98
+ # ---- polling loop ----
99
+
100
+ def _loop(self) -> None:
101
+ while not self._stop_event.is_set():
102
+ if not self._bot_token:
103
+ self._stop_event.wait(IDLE_CHECK_SECONDS)
104
+ continue
105
+ if self._bot_token != self._token_in_use: # new or changed bot: start from its latest update
106
+ self._token_in_use = self._bot_token
107
+ self._last_update_id = None
108
+ self._publish_commands()
109
+ try:
110
+ self._poll_once()
111
+ except Exception:
112
+ logger.exception("Telegram poll failed, backing off")
113
+ self._stop_event.wait(ERROR_BACKOFF_SECONDS)
114
+
115
+ def _poll_once(self) -> None:
116
+ params = {"timeout": POLL_TIMEOUT_SECONDS}
117
+ if self._last_update_id is not None:
118
+ params["offset"] = self._last_update_id + 1
119
+
120
+ resp = requests.get(
121
+ f"{API_ROOT}/bot{self._bot_token}/getUpdates",
122
+ params=params,
123
+ timeout=REQUEST_TIMEOUT_SECONDS,
124
+ )
125
+ resp.raise_for_status()
126
+ payload = resp.json()
127
+ if not payload.get("ok"):
128
+ logger.warning("Telegram getUpdates returned ok=false: %s", payload)
129
+ return
130
+
131
+ for update in payload.get("result", []):
132
+ update_id = update.get("update_id")
133
+ if update_id is not None:
134
+ self._last_update_id = update_id
135
+
136
+ message = update.get("message")
137
+ if not message or "text" not in message:
138
+ continue # edited_message/channel_post/non-text - skip, not an error
139
+
140
+ self._handle_message(message)
141
+
142
+ def _handle_message(self, message: dict) -> None:
143
+ from marketmyna.core import tenant
144
+
145
+ with tenant.system_scope():
146
+ self._handle_message_scoped(message)
147
+
148
+ def _command_scope(self, chat_id: str, is_owner: bool):
149
+ """Commands run as the owner (only the owner's chats get this far)."""
150
+ import contextlib
151
+
152
+ from marketmyna.core import tenant
153
+ from marketmyna.services.accounts import owner_scope
154
+
155
+ if not tenant.enabled():
156
+ return contextlib.nullcontext()
157
+ return owner_scope(self._config)
158
+
159
+ def _handle_message_scoped(self, message: dict) -> None:
160
+ chat_id = str(message["chat"]["id"])
161
+ text = message.get("text", "")
162
+ word = text.strip().split(" ")[0].split("@")[0]
163
+
164
+ is_owner = chat_id in allowed_chats(self._config)
165
+ if not is_owner:
166
+ # Not the owner's chat: never run commands, just explain how to connect.
167
+ if word in ("/start", "/id", "/help") or not allowed_chats(self._config):
168
+ self._reply(chat_id, SETUP_REPLY.format(chat_id=chat_id))
169
+ else:
170
+ logger.info("Ignored Telegram message from unauthorised chat %s", chat_id)
171
+ return
172
+
173
+ user = message.get("from") or {}
174
+ incoming = IncomingMessage(
175
+ platform="telegram",
176
+ chat_id=chat_id,
177
+ text=text,
178
+ user_id=str(user["id"]) if "id" in user else None,
179
+ raw=message,
180
+ )
181
+ if word in ("/start", "/id"):
182
+ self._reply(chat_id, f"✅ Connected to MarketMyna. Chat ID {chat_id}.\nSend /help for commands.")
183
+ return
184
+
185
+ with self._command_scope(chat_id, is_owner):
186
+ result = self._dispatcher.dispatch(incoming)
187
+ if result.text:
188
+ self._reply(chat_id, result.text)
189
+
190
+ def _publish_commands(self) -> None:
191
+ """Telegram's own "/" menu: the registered commands with their one-line descriptions."""
192
+ commands = [{"command": c.name, "description": (c.description or c.help_line())[:256]}
193
+ for c in self._dispatcher.list_commands()][:100]
194
+ try:
195
+ requests.post(f"{API_ROOT}/bot{self._bot_token}/setMyCommands", json={"commands": commands},
196
+ timeout=REQUEST_TIMEOUT_SECONDS)
197
+ except requests.RequestException:
198
+ logger.warning("Could not publish the Telegram command menu")
199
+
200
+ def _reply(self, chat_id: str, text: str) -> None:
201
+ for part in split_message(text):
202
+ try:
203
+ requests.post(
204
+ f"{API_ROOT}/bot{self._bot_token}/sendMessage",
205
+ json={"chat_id": chat_id, "text": part},
206
+ timeout=REQUEST_TIMEOUT_SECONDS,
207
+ )
208
+ except requests.RequestException:
209
+ logger.exception("Failed to send Telegram reply to chat %s", chat_id)
210
+ return
211
+
212
+
213
+ def split_message(text: str, limit: int = 4000) -> list[str]:
214
+ """Telegram caps a message at 4096 characters: split on line breaks."""
215
+ parts, current = [], ""
216
+ for line in text.split("\n"):
217
+ while len(line) > limit:
218
+ parts.append(line[:limit])
219
+ line = line[limit:]
220
+ if current and len(current) + 1 + len(line) > limit:
221
+ parts.append(current)
222
+ current = line
223
+ else:
224
+ current = f"{current}\n{line}" if current else line
225
+ if current:
226
+ parts.append(current)
227
+ return parts
@@ -0,0 +1 @@
1
+ """Command-line entry points (headless runs, e.g. from cron)."""
@@ -0,0 +1,292 @@
1
+ """Headless scheduled analysis: analyze a stock list, then push the report
2
+ to every configured notification channel.
3
+
4
+ Runs anywhere - cron, a server, your laptop:
5
+
6
+ python -m marketmyna.cli.scheduled_run --symbols "RELIANCE,TCS,us:AAPL" --market india
7
+ python -m marketmyna.cli.scheduled_run --watchlist "My watchlist" --dry-run
8
+
9
+ Everything can also come from environment variables, which is how the
10
+ workflow passes GitHub Secrets/Variables in:
11
+
12
+ STOCK_LIST symbols, comma-separated; "market:SYMBOL" overrides the market
13
+ MARKET default market for unprefixed symbols (default: india)
14
+ ANALYSIS_MODE quick | standard | full | specialist (default: standard)
15
+ REPORT_TIMEZONE timestamp timezone in the report (default: Asia/Kolkata)
16
+ TRADING_DAY_CHECK_ENABLED "false" to also run on weekends
17
+ REPORT_LANGUAGE default report language code, e.g. en, hi, ta, zh (default: en)
18
+ REPORT_SECTIONS comma-separated section ids (see marketmyna/services/report.py SECTIONS)
19
+ STOCK_SECTIONS per-stock sections: "RELIANCE=news+fundamentals,TCS=technical"
20
+ CHANNEL_LANGUAGES per-channel language: "telegram:ta,email:en"
21
+ SMART_MESSAGING "true": short top-N message + full report (HTML email/file)
22
+ SMART_TOP_N stocks in the short message (default 5)
23
+ SMART_AI_TOP stocks that get the AI pipeline (default 10; 0 = none)
24
+ SMART_EMAIL_FULL "false": email gets only the short message
25
+ REPORT_BASE_URL public MarketMyna address for full-report links
26
+ MF_LIST AMFI scheme codes for the mutual-fund section: "122639,120503"
27
+ + LLM_* and every notification channel field (TELEGRAM_BOT_TOKEN, ...),
28
+ read by Config.apply_env().
29
+
30
+ Exit codes: 0 = report produced (and delivered, if any channel is set),
31
+ 1 = nothing to analyze or every symbol failed outright,
32
+ 2 = channels are configured but none accepted the message.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import logging
39
+ import os
40
+ import sys
41
+ from datetime import datetime
42
+ from pathlib import Path
43
+ from zoneinfo import ZoneInfo
44
+
45
+ from ..core.config import Config
46
+ from ..notifiers.dispatcher import NotificationDispatcher
47
+ from ..services.analysis_run import (
48
+ MARKET_LABELS,
49
+ analyze_symbol,
50
+ deliver_report,
51
+ is_trading_day,
52
+ notify_failure,
53
+ parse_targets,
54
+ )
55
+ from ..services.report import ReportPrefs, clean_sections
56
+ from ..services.report_i18n import normalize_language
57
+
58
+ logger = logging.getLogger("marketmyna.scheduled_run")
59
+
60
+ MODES = ("quick", "standard", "full", "specialist")
61
+
62
+
63
+ def _env_flag(name: str, default: bool) -> bool:
64
+ value = os.environ.get(name, "").strip().lower()
65
+ if not value:
66
+ return default
67
+ return value in ("1", "true", "yes", "on")
68
+
69
+
70
+ def _watchlist_symbols(config: Config, name: str) -> str:
71
+ """Symbols from a saved in-app watchlist, as a STOCK_LIST-style spec."""
72
+ from ..storage import DatabaseManager, WatchlistRecord, get_db_url
73
+
74
+ db = DatabaseManager.instance(get_db_url(config=config))
75
+ with db.get_session() as session:
76
+ record = session.query(WatchlistRecord).filter(WatchlistRecord.name == name).first()
77
+ if record is None:
78
+ raise SystemExit(f"No watchlist named {name!r}")
79
+ # Funds have no price-chart analysis; skip them.
80
+ return ",".join(f"{i.market}:{i.symbol}" for i in record.items if i.type != "mf")
81
+
82
+
83
+ def _saved_prefs(config: Config) -> ReportPrefs | None:
84
+ """Report preferences saved on the Scheduler page, if this machine has
85
+ the app database (a server run); a fresh GitHub runner has none."""
86
+ try:
87
+ from ..services.schedule_config import ScheduleConfigStore
88
+ from ..storage import has_app_db
89
+
90
+ if not has_app_db(config):
91
+ return None
92
+ return ScheduleConfigStore(config=config).load().report_prefs()
93
+ except Exception as exc:
94
+ logger.warning("Saved report preferences unreadable: %s", exc)
95
+ return None
96
+
97
+
98
+ def parse_pairs(raw: str, sep: str) -> dict[str, str]:
99
+ """"telegram:ta,email:en" -> {"telegram": "ta", "email": "en"}."""
100
+ out = {}
101
+ for part in raw.split(","):
102
+ if sep in part:
103
+ key, value = part.split(sep, 1)
104
+ if key.strip() and value.strip():
105
+ out[key.strip()] = value.strip()
106
+ return out
107
+
108
+
109
+ def parse_list(raw: str) -> list[str]:
110
+ return [part.strip() for part in raw.split(",") if part.strip()]
111
+
112
+
113
+ def build_prefs(
114
+ config: Config, mode: str, timezone: str, language: str | None = None
115
+ ) -> ReportPrefs:
116
+ """Saved Scheduler preferences, overridden by env vars / flags."""
117
+ prefs = _saved_prefs(config)
118
+ if prefs is None:
119
+ prefs = ReportPrefs()
120
+ prefs.mode, prefs.timezone = mode, timezone
121
+ if language or os.environ.get("REPORT_LANGUAGE", "").strip():
122
+ prefs.language = normalize_language(language or os.environ["REPORT_LANGUAGE"])
123
+ if os.environ.get("REPORT_SECTIONS", "").strip():
124
+ prefs.sections = clean_sections(os.environ["REPORT_SECTIONS"].split(","))
125
+ if os.environ.get("STOCK_SECTIONS", "").strip():
126
+ prefs.stock_sections = {
127
+ sym.upper(): clean_sections(secs.replace("|", "+").split("+"))
128
+ for sym, secs in parse_pairs(os.environ["STOCK_SECTIONS"], "=").items()
129
+ }
130
+ if os.environ.get("MF_LIST", "").strip():
131
+ prefs.funds = [c for c in parse_list(os.environ["MF_LIST"]) if c.isdigit()]
132
+ if os.environ.get("SMART_MESSAGING", "").strip():
133
+ prefs.smart = os.environ["SMART_MESSAGING"].strip().lower() in ("1", "true", "yes", "on")
134
+ if os.environ.get("SMART_TOP_N", "").strip():
135
+ prefs.smart_top_n = max(1, min(20, int(os.environ["SMART_TOP_N"])))
136
+ if os.environ.get("SMART_AI_TOP", "").strip():
137
+ prefs.smart_ai_top = max(0, int(os.environ["SMART_AI_TOP"]))
138
+ if os.environ.get("SMART_EMAIL_FULL", "").strip():
139
+ prefs.smart_email_full = os.environ["SMART_EMAIL_FULL"].strip().lower() not in ("0", "false", "no", "off")
140
+ if os.environ.get("REPORT_BASE_URL", "").strip():
141
+ prefs.report_base_url = os.environ["REPORT_BASE_URL"].strip()
142
+ if os.environ.get("CHANNEL_LANGUAGES", "").strip():
143
+ prefs.channel_languages = {
144
+ ch.lower(): normalize_language(lang) for ch, lang in parse_pairs(os.environ["CHANNEL_LANGUAGES"], ":").items()
145
+ }
146
+ return prefs
147
+
148
+
149
+ def _run_smart(targets, prefs, config, args) -> int:
150
+ """Smart messaging: top-N message + full report (HTML file here, since
151
+ a GitHub runner has no server to host the link)."""
152
+ from ..services.smart_report import run_smart
153
+
154
+ logger.info("Smart messaging: %d stocks, AI for the top %d, message shows top %d",
155
+ len(targets), prefs.smart_ai_top, prefs.smart_top_n)
156
+ dispatcher = NotificationDispatcher(config)
157
+ reports, delivery, rows, token, full_html = run_smart(targets, prefs, config, dispatcher=dispatcher,
158
+ dry_run=args.dry_run)
159
+ out_dir = Path(args.output_dir)
160
+ out_dir.mkdir(parents=True, exist_ok=True)
161
+ stamp = datetime.now(ZoneInfo(args.timezone)).strftime("%Y%m%d-%H%M")
162
+ from ..services.smart_report import html_report
163
+
164
+ (out_dir / f"marketmyna-{stamp}-full.html").write_text(
165
+ full_html or html_report(rows, f"MarketMyna · {len(rows)} stocks", datetime.now(ZoneInfo(args.timezone)), None),
166
+ encoding="utf-8")
167
+ for lang, (title, body) in reports.items():
168
+ print(f"===== {title} [{lang}] =====\n{body}\n")
169
+ (out_dir / f"marketmyna-{stamp}-{lang}.txt").write_text(f"{title}\n\n{body}\n", encoding="utf-8")
170
+ good = [r for r in rows if "error" not in r]
171
+ if not good:
172
+ logger.error("Every symbol failed - check the symbols, market and data sources above.")
173
+ if not args.dry_run:
174
+ notify_failure(config, "No stock could be analyzed.", dispatcher=dispatcher)
175
+ return 1
176
+ for name, ok in delivery.items():
177
+ logger.info("Notification %-12s %s", name, "sent" if ok else "FAILED")
178
+ if delivery and not any(delivery.values()):
179
+ return 2
180
+ return 0
181
+
182
+
183
+ def build_parser() -> argparse.ArgumentParser:
184
+ parser = argparse.ArgumentParser(prog="python -m marketmyna.cli.scheduled_run", description=__doc__.split("\n\n")[0])
185
+ parser.add_argument("--symbols", default=os.environ.get("STOCK_LIST", ""), help="comma-separated; env STOCK_LIST")
186
+ parser.add_argument("--watchlist", help="use a saved in-app watchlist instead of --symbols")
187
+ parser.add_argument(
188
+ "--market", default=os.environ.get("MARKET", "india").strip().lower() or "india", choices=sorted(MARKET_LABELS)
189
+ )
190
+ parser.add_argument(
191
+ "--mode", default=os.environ.get("ANALYSIS_MODE", "standard").strip().lower() or "standard", choices=MODES
192
+ )
193
+ parser.add_argument("--timezone", default=os.environ.get("REPORT_TIMEZONE", "Asia/Kolkata") or "Asia/Kolkata")
194
+ parser.add_argument("--language", help="report language code (env REPORT_LANGUAGE)")
195
+ parser.add_argument("--force-run", action="store_true", help="run even on weekends")
196
+ parser.add_argument("--dry-run", action="store_true", help="print the report, don't send it")
197
+ parser.add_argument("--output-dir", default="reports", help="where to save the report file")
198
+ return parser
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> int:
202
+ logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
203
+ args = build_parser().parse_args(argv)
204
+ config = Config.load()
205
+ if config.accounts_enabled:
206
+ # Accounts mode: report on the platform owner's lists, like the in-app scheduler.
207
+ from ..core import tenant
208
+ from ..services.accounts import owner_scope
209
+
210
+ tenant.enable()
211
+ with owner_scope(config):
212
+ return _main(args, config)
213
+ return _main(args, config)
214
+
215
+
216
+ def _main(args, config) -> int:
217
+
218
+ spec = _watchlist_symbols(config, args.watchlist) if args.watchlist else args.symbols
219
+ targets = parse_targets(spec, args.market)
220
+ if not targets:
221
+ logger.error("No symbols to analyze. Set STOCK_LIST (e.g. RELIANCE,TCS,INFY) or pass --symbols.")
222
+ return 1
223
+
224
+ check_days = _env_flag("TRADING_DAY_CHECK_ENABLED", True) and not args.force_run
225
+ if check_days:
226
+ open_targets = [t for t in targets if is_trading_day(t.market)]
227
+ skipped = len(targets) - len(open_targets)
228
+ if skipped:
229
+ logger.info("Skipping %d symbol(s): market closed today (use --force-run to override)", skipped)
230
+ targets = open_targets
231
+ if not targets:
232
+ logger.info("All markets closed today - nothing to do.")
233
+ return 0
234
+
235
+ from ..llm.channels import build_routes
236
+
237
+ routes = build_routes(config)
238
+ if routes:
239
+ logger.info("AI routes (in order): %s", ", ".join(r.label() for r in routes))
240
+ else:
241
+ logger.warning("No AI model configured (LLM_MODEL / LLM_CHANNELS): reports carry technical/risk signals but no AI decision.")
242
+
243
+ prefs = build_prefs(config, args.mode, args.timezone, args.language)
244
+ logger.info("Report: language=%s sections=%s", prefs.language, ",".join(prefs.sections))
245
+ if prefs.smart:
246
+ return _run_smart(targets, prefs, config, args)
247
+
248
+ results = []
249
+ for i, target in enumerate(targets, 1):
250
+ logger.info("[%d/%d] Analyzing %s (%s, %s)", i, len(targets), target.symbol, target.market, args.mode)
251
+ result = analyze_symbol(
252
+ target.symbol, target.market, args.mode, config, sections=prefs.sections_for(target.symbol)
253
+ )
254
+ if not result["ok"]:
255
+ logger.warning("%s: %s", target.symbol, result.get("error"))
256
+ results.append(result)
257
+
258
+ produced_anything = any(r.get("dashboard") for r in results)
259
+ if not produced_anything:
260
+ logger.error("Every symbol failed - check the symbols, market and data sources above.")
261
+
262
+ dispatcher = NotificationDispatcher(config)
263
+ reports, delivery = deliver_report(results, prefs, config, dispatcher=dispatcher, dry_run=args.dry_run)
264
+
265
+ out_dir = Path(args.output_dir)
266
+ out_dir.mkdir(parents=True, exist_ok=True)
267
+ stamp = datetime.now(ZoneInfo(args.timezone)).strftime("%Y%m%d-%H%M")
268
+ for lang, (title, body) in reports.items():
269
+ print(f"===== {title} [{lang}] =====\n{body}\n")
270
+ (out_dir / f"marketmyna-{stamp}-{lang}.txt").write_text(f"{title}\n\n{body}\n", encoding="utf-8")
271
+
272
+ if args.dry_run:
273
+ logger.info("Dry run: not sending.")
274
+ return 0 if produced_anything else 1
275
+ if not produced_anything and not args.dry_run:
276
+ errors = "\n".join(f"{r['symbol']}: {r.get('error', 'no data')}" for r in results)
277
+ notify_failure(config, f"No stock could be analyzed.\n{errors}", dispatcher=dispatcher)
278
+
279
+ if not dispatcher.configured_channels("report"):
280
+ logger.warning("No notification channel configured (e.g. TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID); report only printed.")
281
+ return 0 if produced_anything else 1
282
+
283
+ for name, ok in delivery.items():
284
+ lang = normalize_language(prefs.channel_languages.get(name) or prefs.language)
285
+ logger.info("Notification %-12s [%s] %s", name, lang, "sent" if ok else "FAILED")
286
+ if not any(delivery.values()):
287
+ return 2
288
+ return 0 if produced_anything else 1
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ """Core module - Configuration and constants."""
2
+
3
+ from .config import Config
4
+ from .constants import Markets, TimeFrame
5
+
6
+ __all__ = ["Config", "Markets", "TimeFrame"]
@@ -0,0 +1,53 @@
1
+ """What the app may say about a single stock: facts, explanations and
2
+ comparisons, never a call.
3
+
4
+ MarketMyna never gives a buy / hold / sell call, price target, entry, exit
5
+ or stop loss. Where a direction helps, it is shown as an outlook: bullish,
6
+ neutral or bearish (outlook() below). The frontend reads mode() ("analytics")
7
+ to pick its wording.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ MODE = "analytics"
13
+
14
+
15
+ def mode() -> str:
16
+ return MODE
17
+
18
+
19
+ def mode_for(user_id) -> str:
20
+ """mode() for the /me endpoint (the frontend reads it to pick the wording)."""
21
+ return MODE
22
+
23
+
24
+ # A buy / hold / sell style rating (an analyst consensus, an AI reply) as a direction.
25
+ OUTLOOKS = {"strong_buy": "bullish", "buy": "bullish", "hold": "neutral", "sell": "bearish", "strong_sell": "bearish"}
26
+
27
+
28
+ def outlook(rating) -> "str | None":
29
+ """bullish / neutral / bearish for a rating (or an outlook already),
30
+ None when there is none."""
31
+ value = str(rating or "").lower()
32
+ return value if value in ("bullish", "neutral", "bearish") else OUTLOOKS.get(value)
33
+
34
+
35
+ # Appended to every AI prompt that writes about a single security, so no
36
+ # agent slips a call into its prose.
37
+ NO_CALLS_RULE = (
38
+ "This is an analytics tool that is not registered with SEBI as an investment adviser or research "
39
+ "analyst. Describe what the data shows as observations. Never recommend buying, holding, selling, "
40
+ "accumulating, booking profit or exiting, and never give a price target, entry, exit or stop loss. A "
41
+ "direction may be described only as a bullish, neutral or bearish outlook, never as an instruction. Do "
42
+ "not use words like attractive, cheap, expensive, undervalued or overvalued as a verdict."
43
+ )
44
+
45
+
46
+ def guard_prompt(system_prompt: str) -> str:
47
+ """The system prompt, plus NO_CALLS_RULE."""
48
+ return f"{system_prompt}\n\n{NO_CALLS_RULE}"
49
+
50
+
51
+ # Response fields that carry a call, target or stop; always stripped.
52
+ CALL_FIELDS = ("decision_type", "target_price", "stop_loss", "buy_point", "sell_point", "action",
53
+ "ai_target", "ai_stop", "technical_signal")