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,491 @@
1
+ """One scheduled-analysis run: analyze a list of symbols and turn the
2
+ results into a human-readable report for notification channels.
3
+
4
+ Shared by the in-app RuntimeSchedulerService and the headless runner
5
+ (marketmyna/cli/scheduled_run.py, for cron or a server), so a scheduled
6
+ message looks the same no matter which of them sent it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from typing import Optional
15
+ from zoneinfo import ZoneInfo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ MARKET_LABELS = {
20
+ "us": "US",
21
+ "india": "India",
22
+ "china_a": "China A",
23
+ "hong_kong": "Hong Kong",
24
+ "crypto": "Crypto",
25
+ "global": "Global",
26
+ }
27
+ # Timezone the trading-day check and report timestamp use, per market.
28
+ MARKET_TIMEZONES = {
29
+ "india": "Asia/Kolkata",
30
+ "us": "America/New_York",
31
+ "china_a": "Asia/Shanghai",
32
+ "hong_kong": "Asia/Hong_Kong",
33
+ }
34
+
35
+
36
+
37
+ @dataclass
38
+ class Target:
39
+ symbol: str
40
+ market: str
41
+
42
+
43
+ def parse_targets(spec: str, default_market: str) -> list[Target]:
44
+ """"RELIANCE, TCS, us:AAPL" -> targets; a "market:" prefix overrides
45
+ the default market for that one symbol. Duplicates are dropped."""
46
+ seen: set[tuple[str, str]] = set()
47
+ targets: list[Target] = []
48
+ for raw in spec.replace("\n", ",").split(","):
49
+ item = raw.strip()
50
+ if not item:
51
+ continue
52
+ market = default_market
53
+ if ":" in item:
54
+ prefix, rest = item.split(":", 1)
55
+ if prefix.lower() in MARKET_LABELS:
56
+ market, item = prefix.lower(), rest.strip()
57
+ key = (item.upper(), market)
58
+ if item and key not in seen:
59
+ seen.add(key)
60
+ targets.append(Target(item.upper(), market))
61
+ return targets
62
+
63
+
64
+ def is_trading_day(market: str, now: Optional[datetime] = None) -> bool:
65
+ """Weekday check in the market's own timezone. Crypto never closes.
66
+ Exchange holidays aren't known here - a holiday run just reports
67
+ the previous close."""
68
+ if market in ("crypto", "global"):
69
+ return True
70
+ tz = ZoneInfo(MARKET_TIMEZONES.get(market, "UTC"))
71
+ now = now.astimezone(tz) if now else datetime.now(tz)
72
+ return now.weekday() < 5
73
+
74
+
75
+ def _quote(symbol: str, market: str) -> dict:
76
+ """Best-effort price/change for the report header line."""
77
+ try:
78
+ from ..core.constants import Markets
79
+ from ..data.china import ChinaAFetcher, HongKongFetcher
80
+ from ..data.crypto import CryptoDataFetcher
81
+ from ..data.india import IndiaDataFetcher
82
+ from ..data.stocks import StockDataFetcher
83
+
84
+ fetcher = {
85
+ Markets.INDIA: IndiaDataFetcher,
86
+ Markets.CRYPTO: CryptoDataFetcher,
87
+ Markets.CHINA_A: ChinaAFetcher,
88
+ Markets.HONG_KONG: HongKongFetcher,
89
+ }.get(Markets(market), StockDataFetcher)()
90
+ return fetcher.get_quote(symbol) or {}
91
+ except Exception as exc: # the report is still useful without a price
92
+ logger.warning("Quote for %s (%s) unavailable: %s", symbol, market, exc)
93
+ return {}
94
+
95
+
96
+ # Yahoo Finance ticker for markets whose native codes differ.
97
+ def yahoo_symbol(symbol: str, market: str) -> str:
98
+ s = symbol.upper()
99
+ if market == "india":
100
+ return s if s.startswith("^") or s.endswith((".NS", ".BO")) else f"{s}.NS"
101
+ if market == "china_a":
102
+ return f"{s}.SS" if s[:1] in "569" else f"{s}.BJ" if s[:1] in "48" else f"{s}.SZ"
103
+ if market == "hong_kong":
104
+ return f"{int(s):04d}.HK" if s.isdigit() else s
105
+ if market == "crypto":
106
+ return s if "-" in s else f"{s}-USD"
107
+ return s
108
+
109
+
110
+ MARKET_INDICES = {
111
+ "india": [("^NSEI", "NIFTY 50"), ("^BSESN", "SENSEX"), ("^NSEBANK", "NIFTY Bank")],
112
+ "us": [("^GSPC", "S&P 500"), ("^IXIC", "Nasdaq"), ("^DJI", "Dow Jones")],
113
+ "china_a": [("000001.SS", "Shanghai Composite"), ("399001.SZ", "Shenzhen Component")],
114
+ "hong_kong": [("^HSI", "Hang Seng")],
115
+ "crypto": [("BTC-USD", "Bitcoin"), ("ETH-USD", "Ethereum")],
116
+ }
117
+
118
+
119
+ def market_overview(markets) -> list[dict]:
120
+ """Index level + day change for each market in the run (best effort)."""
121
+ from ..data.stocks import StockDataFetcher
122
+
123
+ fetcher = StockDataFetcher()
124
+ out = []
125
+ for market in dict.fromkeys(markets):
126
+ for ticker, name in MARKET_INDICES.get(market, []):
127
+ try:
128
+ q = fetcher.get_quote(ticker)
129
+ out.append({"market": market, "name": name, "price": q.get("price"),
130
+ "change_percent": q.get("change_percent")})
131
+ except Exception as exc:
132
+ logger.warning("Index %s unavailable: %s", ticker, exc)
133
+ return out
134
+
135
+
136
+ def _to_str_date(value):
137
+ if isinstance(value, (list, tuple)):
138
+ value = value[0] if value else None
139
+ return value.isoformat() if hasattr(value, "isoformat") else (str(value) if value else None)
140
+
141
+
142
+ def collect_extras(symbol: str, market: str, sections, ctx=None) -> dict:
143
+ """Per-stock data beyond the agent pipeline, fetched only for the
144
+ sections someone actually asked for. Every piece is best effort."""
145
+ sections = set(sections or [])
146
+ extras: dict = {}
147
+ yf_symbol = yahoo_symbol(symbol, market)
148
+ info: dict = {}
149
+
150
+ def load_info() -> dict:
151
+ nonlocal info
152
+ if not info and market != "crypto":
153
+ try:
154
+ import yfinance as yf
155
+
156
+ info = yf.Ticker(yf_symbol).info or {}
157
+ except Exception as exc:
158
+ logger.warning("Info for %s unavailable: %s", yf_symbol, exc)
159
+ return info
160
+
161
+ # The movers list flags stocks near their 52-week high / low, so it needs the range too.
162
+ if "price" in sections or "calls" in sections:
163
+ data = load_info() if market != "crypto" else {}
164
+ extras["high_52w"] = data.get("fiftyTwoWeekHigh")
165
+ extras["low_52w"] = data.get("fiftyTwoWeekLow")
166
+ extras["name"] = data.get("shortName") or data.get("longName")
167
+
168
+ if "fundamentals" in sections and market != "crypto":
169
+ fundamentals = getattr(ctx, "fundamental_summary", None) if ctx else None
170
+ if not fundamentals and load_info():
171
+ try:
172
+ from ..analysis.fundamental import FundamentalAnalyzer, yfinance_fundamentals
173
+
174
+ fundamentals = FundamentalAnalyzer(yfinance_fundamentals(info)).get_summary()
175
+ except Exception as exc:
176
+ logger.warning("Fundamentals for %s unavailable: %s", symbol, exc)
177
+ if fundamentals:
178
+ extras["fundamentals"] = {
179
+ k: fundamentals.get(k)
180
+ for k in ("overall_score", "valuation", "profitability", "financial_health", "growth", "dividend")
181
+ }
182
+
183
+ if "analysts" in sections and load_info().get("recommendationKey"):
184
+ extras["analysts"] = {
185
+ "recommendation": info.get("recommendationKey"),
186
+ "target_mean": info.get("targetMeanPrice"),
187
+ "count": info.get("numberOfAnalystOpinions"),
188
+ }
189
+
190
+ if "events" in sections and market != "crypto":
191
+ try:
192
+ import yfinance as yf
193
+
194
+ cal = yf.Ticker(yf_symbol).calendar or {}
195
+ extras["events"] = {
196
+ "earnings_date": _to_str_date(cal.get("Earnings Date")),
197
+ "ex_dividend_date": _to_str_date(cal.get("Ex-Dividend Date")),
198
+ }
199
+ except Exception as exc:
200
+ logger.warning("Calendar for %s unavailable: %s", yf_symbol, exc)
201
+
202
+ if "valuation" in sections and market != "crypto":
203
+ try:
204
+ from .valuation import meter
205
+
206
+ v = meter(symbol, market)
207
+ if v.get("status") == "ok":
208
+ extras["valuation"] = {k: v.get(k) for k in ("position", "zone", "multiples", "sector")}
209
+ except Exception as exc:
210
+ logger.warning("Valuation for %s unavailable: %s", symbol, exc)
211
+
212
+ if "levels" in sections:
213
+ # Daily-bar levels (support, resistance, stop, ATR, ranges) from the
214
+ # history the Technical step already fetched.
215
+ try:
216
+ from .smart_report import compute_snapshot
217
+
218
+ history = getattr(ctx, "history", None) if ctx else None
219
+ if history is None:
220
+ from ..agent.agents import _resolve_fetcher
221
+
222
+ history = _resolve_fetcher(market).get_history(symbol, period="1y")
223
+ snap = compute_snapshot(history)
224
+ extras["levels"] = {k: snap.get(k) for k in (
225
+ "price", "buy_point", "sell_point", "stop_loss", "atr", "normal_low", "normal_high",
226
+ "week_low", "week_high", "trend")}
227
+ except Exception as exc:
228
+ logger.warning("Trade levels for %s unavailable: %s", symbol, exc)
229
+
230
+ if "news" in sections:
231
+ # The pipeline's News step already fetched headlines; reuse them.
232
+ news = getattr(ctx, "news", None) if ctx else None
233
+ if news is None:
234
+ try:
235
+ from ..data.news_search import stock_news
236
+
237
+ news = stock_news(symbol, market, limit=3, name=extras.get("name"))
238
+ except Exception as exc:
239
+ logger.warning("News for %s unavailable: %s", symbol, exc)
240
+ news = []
241
+ extras["news"] = news[:3]
242
+ return extras
243
+
244
+
245
+ def analyze_symbol(symbol: str, market: str, mode: str, config, orchestrator=None, sections=None) -> dict:
246
+ """Run the agent pipeline for one symbol, never raising.
247
+
248
+ If a critical stage (normally the AI decision) fails, the opinions
249
+ that did finish (technical, risk) are still returned in `dashboard`
250
+ with ok=False, so the report can show the technical read instead of
251
+ only an error. `sections` (report section ids) decides which extra
252
+ data - news, fundamentals, analysts, events - is fetched.
253
+ """
254
+ from ..agent.context import AgentContext
255
+ from ..agent.dashboard import build_dashboard
256
+ from ..agent.orchestrator import AgentOrchestrator
257
+
258
+ ctx = AgentContext(symbol=symbol, market=market, mode=mode)
259
+ result: dict = {"symbol": symbol, "market": market, "quote": _quote(symbol, market)}
260
+ try:
261
+ orchestrator = orchestrator or AgentOrchestrator(config=config)
262
+ orchestrator.run(symbol, market, mode=mode, ctx=ctx)
263
+ result.update(ok=True, dashboard=build_dashboard(ctx))
264
+ except Exception as exc:
265
+ result.update(
266
+ ok=False,
267
+ error=str(exc) or type(exc).__name__,
268
+ dashboard=build_dashboard(ctx) if ctx.opinions else None,
269
+ )
270
+ if sections:
271
+ extras = collect_extras(symbol, market, sections, ctx)
272
+ result["name"] = extras.pop("name", None)
273
+ result["extras"] = extras
274
+ return result
275
+
276
+
277
+ def notify_failure(config, summary: str, dispatcher=None) -> dict[str, bool]:
278
+ """Tell the system-error channels a scheduled run failed, so a silent
279
+ morning (no report) never goes unnoticed. Never raises."""
280
+ try:
281
+ from ..notifiers.dispatcher import NotificationDispatcher
282
+
283
+ dispatcher = dispatcher or NotificationDispatcher(config)
284
+ return dispatcher.send_all(summary, title="⚠️ MarketMyna scheduled run failed", purpose="system_error")
285
+ except Exception:
286
+ logger.exception("Could not send failure notification")
287
+ return {}
288
+
289
+
290
+ def deliver_report(results: list[dict], prefs, config, dispatcher=None, dry_run: bool = False):
291
+ """Render the report once per language any channel wants, then send
292
+ each configured channel its own language.
293
+
294
+ Returns (reports, delivery): reports maps language -> (title, body)
295
+ and always includes prefs.language; delivery maps channel -> sent ok.
296
+ """
297
+ from ..notifiers.dispatcher import NotificationDispatcher
298
+ from .report import market_review, render_reports
299
+ from .report_i18n import normalize_language
300
+
301
+ dispatcher = dispatcher or NotificationDispatcher(config)
302
+ channels = dispatcher.configured_channels("report")
303
+ by_lang = prefs.languages_needed(channels)
304
+ languages = [normalize_language(prefs.language), *by_lang]
305
+
306
+ overview = market_take = None
307
+ if "market" in prefs.sections:
308
+ overview = market_overview(r["market"] for r in results)
309
+ market_take = market_review(overview, results, config)
310
+ funds = None
311
+ if "funds" in prefs.sections and prefs.funds:
312
+ from .funds import fund_snapshots
313
+
314
+ funds = fund_snapshots(prefs.funds)
315
+ try: # lumpsum window / dip check per fund (cached, equity and hybrid only)
316
+ from .mf_lumpsum import assess_fund
317
+
318
+ for f in funds:
319
+ f["lumpsum"] = assess_fund(f["code"], f.get("category") or "", f.get("name") or "")
320
+ except Exception as exc:
321
+ logger.warning("Fund dip check unavailable: %s", exc)
322
+
323
+ sections = prefs.all_sections()
324
+ portfolios = portfolio_snapshots(prefs.portfolios, results, config) if "portfolio" in sections else None
325
+ alerts = alert_digest(results, config) if "alerts" in sections else None
326
+ hot = hot_blocks(results, sections)
327
+
328
+ reports = render_reports(results, prefs, languages, config=config, overview=overview,
329
+ funds=funds, market_take=market_take,
330
+ portfolios=portfolios, alerts=alerts, hot=hot)
331
+ messages = {ch: reports[lang] for lang, chs in by_lang.items() for ch in chs}
332
+ if dry_run or not messages:
333
+ return reports, {}
334
+ return reports, dispatcher.send_each(messages, purpose="report")
335
+
336
+
337
+ def _one_user_scope() -> bool:
338
+ """Personal report data (portfolios, alerts) is only read for one user:
339
+ in accounts mode a SYSTEM or missing scope would mix every user's rows."""
340
+ from ..core import tenant
341
+
342
+ return not tenant.enabled() or tenant.current_user_id() is not None
343
+
344
+
345
+ CURRENCY = {"india": "₹", "us": "$", "china_a": "¥", "hong_kong": "HK$", "crypto": "$", "mf": "₹"}
346
+
347
+
348
+ def portfolio_snapshots(names: list[str], results: list[dict], config=None, quote=None) -> list[dict]:
349
+ """Value, today's change and total gain of each portfolio (all of them
350
+ when `names` is empty), priced from the report's own quotes where it has
351
+ them. Mutual funds use their latest NAV. Best effort, never raises."""
352
+ from ..storage import DatabaseManager, HoldingRecord, PortfolioRecord, get_db_url
353
+
354
+ if not _one_user_scope():
355
+ return []
356
+ quote = quote or _quote
357
+ known = {(r["symbol"].upper(), r["market"]): r.get("quote") or {} for r in results}
358
+ out = []
359
+ try:
360
+ with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
361
+ rows = []
362
+ for p in session.query(PortfolioRecord).order_by(PortfolioRecord.id):
363
+ if names and p.name not in names:
364
+ continue
365
+ holdings = [(h.symbol, (h.market or "us").lower(), h.quantity or 0, h.avg_cost or 0, h.current_price or 0)
366
+ for h in session.query(HoldingRecord).filter(HoldingRecord.portfolio_id == p.id)
367
+ if (h.quantity or 0) > 0]
368
+ rows.append((p.name, holdings))
369
+ except Exception as exc:
370
+ logger.warning("Portfolios unavailable for the report: %s", exc)
371
+ return []
372
+ for name, holdings in rows:
373
+ if not holdings:
374
+ continue
375
+ value = cost = day = prev_value = 0.0
376
+ movers, markets = [], set()
377
+ for symbol, market, qty, avg, last in holdings:
378
+ markets.add(market)
379
+ price = change = None
380
+ if market == "mf":
381
+ try:
382
+ from .mf_holdings import latest
383
+
384
+ info = latest(symbol) or {}
385
+ price, change = info.get("nav"), None
386
+ except Exception:
387
+ pass
388
+ else:
389
+ q = known.get((symbol.upper(), market))
390
+ if q is None:
391
+ try:
392
+ q = quote(symbol, market)
393
+ except Exception:
394
+ q = {}
395
+ price, change = q.get("price"), q.get("change")
396
+ price = price or last
397
+ if not price:
398
+ continue
399
+ value += qty * price
400
+ cost += qty * avg
401
+ if change is not None:
402
+ day += qty * change
403
+ prev_value += qty * (price - change)
404
+ movers.append({"symbol": symbol.upper(), "change": qty * change,
405
+ "change_pct": change / (price - change) * 100 if price - change else None})
406
+ else:
407
+ prev_value += qty * price
408
+ movers.sort(key=lambda m: -abs(m["change"]))
409
+ cur = CURRENCY.get(next(iter(markets)), "") if len(markets - {"mf"}) <= 1 else ""
410
+ out.append({
411
+ "name": name, "value": value, "currency_symbol": cur,
412
+ "day_change": day, "day_change_pct": day / prev_value * 100 if prev_value else None,
413
+ "gain": value - cost if cost else None, "gain_pct": (value - cost) / cost * 100 if cost else None,
414
+ "movers": [m for m in movers[:3] if m["change"]],
415
+ })
416
+ return out
417
+
418
+
419
+ def alert_digest(results: list[dict], config=None, now: Optional[datetime] = None, quote=None,
420
+ near_pct: float = 3.0) -> dict:
421
+ """The user's price alerts for the report: what fired in the last day, and
422
+ active price alerts within `near_pct` of their level. Never raises."""
423
+ from datetime import timedelta
424
+
425
+ from ..storage import DatabaseManager, PriceAlertEventRecord, PriceAlertRecord, get_db_url
426
+ from ..trading.alerts import describe
427
+
428
+ if not _one_user_scope():
429
+ return {}
430
+ quote = quote or _quote
431
+ now = now or datetime.now()
432
+ known = {(r["symbol"].upper(), r["market"]): r.get("quote") or {} for r in results}
433
+ try:
434
+ with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
435
+ fired = [{"symbol": e.symbol, "message": e.message, "at": e.created_at}
436
+ for e in session.query(PriceAlertEventRecord)
437
+ .filter(PriceAlertEventRecord.created_at >= now - timedelta(hours=24))
438
+ .order_by(PriceAlertEventRecord.created_at.desc())]
439
+ active = [(a.symbol, a.market, a.kind, a.value)
440
+ for a in session.query(PriceAlertRecord).filter(PriceAlertRecord.status == "active")]
441
+ except Exception as exc:
442
+ logger.warning("Alerts unavailable for the report: %s", exc)
443
+ return {}
444
+ near = []
445
+ for symbol, market, kind, level in active:
446
+ if kind not in ("price_above", "price_below") or not level:
447
+ continue
448
+ q = known.get((symbol.upper(), market))
449
+ if q is None:
450
+ try:
451
+ q = quote(symbol, market)
452
+ except Exception:
453
+ continue
454
+ known[(symbol.upper(), market)] = q
455
+ price = q.get("price")
456
+ if not price:
457
+ continue
458
+ distance = (level - price) / price * 100
459
+ if abs(distance) <= near_pct:
460
+ near.append({"symbol": symbol, "describe": describe(kind, level), "distance_pct": distance})
461
+ near.sort(key=lambda a: abs(a["distance_pct"]))
462
+ return {"fired": fired, "near": near, "active": len(active)}
463
+
464
+
465
+ # Boards each hot section shows in a report, three rows each.
466
+ HOT_BOARDS = {"hot_trade": ("trader", ("momentum", "activity", "breakouts")),
467
+ "hot_invest": ("investor", ("quality_dip", "earnings", "funds_adding"))}
468
+
469
+
470
+ def hot_blocks(results: list[dict], sections, rows_per_board: int = 3) -> list[dict]:
471
+ """Hot / trending stocks for the report's stock markets (marketmyna/services/hot_stocks.py)."""
472
+ from . import hot_stocks
473
+ from .discovery import DISCOVERY_MARKETS
474
+
475
+ markets = [m for m in dict.fromkeys(r["market"] for r in results) if m in DISCOVERY_MARKETS]
476
+ out = []
477
+ for section, (area, keys) in HOT_BOARDS.items():
478
+ if section not in sections or not markets:
479
+ continue
480
+ per_market = {}
481
+ for market in markets:
482
+ try:
483
+ data = hot_stocks.trader(market) if area == "trader" else hot_stocks.investor(market)
484
+ except Exception as exc:
485
+ logger.warning("Hot stocks (%s, %s) unavailable: %s", area, market, exc)
486
+ continue
487
+ boards = [{**b, "rows": b["rows"][:rows_per_board]} for b in data["boards"] if b["key"] in keys]
488
+ per_market[market] = boards
489
+ out.append({"section": section, "area": area, "markets": per_market})
490
+ return out
491
+