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,34 @@
1
+ """Mutual Fund data fetcher for Indian mutual funds."""
2
+
3
+ import pandas as pd
4
+ import requests
5
+
6
+
7
+ class MutualFundDataFetcher:
8
+ """Fetches mutual fund data from MFAPI.in (free API for Indian MFs)."""
9
+
10
+ BASE_URL = "https://api.mfapi.in"
11
+
12
+ def get_historical_nav(self, scheme_code: int, days: int = 365) -> pd.DataFrame:
13
+ """Get historical NAV data for a scheme."""
14
+ try:
15
+ response = requests.get(f"{self.BASE_URL}/mf/{scheme_code}", timeout=10)
16
+ response.raise_for_status()
17
+ data = response.json()
18
+
19
+ nav_data = data.get("data", [])[:days]
20
+
21
+ df = pd.DataFrame(nav_data)
22
+ if not df.empty:
23
+ df["date"] = pd.to_datetime(df["date"], format="%d-%m-%Y")
24
+ df["nav"] = df["nav"].astype(float)
25
+ df = df.sort_values("date")
26
+ df.set_index("date", inplace=True)
27
+
28
+ return df
29
+
30
+ except Exception as e:
31
+ print(f"Error fetching historical NAV for {scheme_code}: {e}")
32
+ return pd.DataFrame()
33
+
34
+
@@ -0,0 +1,98 @@
1
+ """News data fetcher for stocks."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class NewsArticle:
10
+ """Represents a news article."""
11
+ title: str
12
+ summary: str
13
+ source: str
14
+ url: str
15
+ published: datetime
16
+ sentiment: Optional[str] = None # positive, negative, neutral
17
+ symbols: list[str] = None
18
+
19
+ def to_dict(self) -> dict:
20
+ return {
21
+ "title": self.title,
22
+ "summary": self.summary,
23
+ "source": self.source,
24
+ "url": self.url,
25
+ "published": self.published.isoformat() if self.published else None,
26
+ "sentiment": self.sentiment,
27
+ "symbols": self.symbols or [],
28
+ }
29
+
30
+
31
+ class NewsFetcher:
32
+ """Fetches a stock's news from Yahoo Finance."""
33
+
34
+ def get_stock_news(self, symbol: str, limit: int = 10) -> list[NewsArticle]:
35
+ """Get news for a specific stock using yfinance."""
36
+ try:
37
+ import yfinance as yf
38
+
39
+ ticker = yf.Ticker(symbol)
40
+ news = ticker.news
41
+
42
+ articles = []
43
+ for item in news[:limit]:
44
+ # Handle both old and new yfinance API formats
45
+ content = item.get("content", item) # New API nests data in 'content'
46
+
47
+ # Extract title
48
+ title = content.get("title", item.get("title", ""))
49
+
50
+ # Extract summary
51
+ summary = content.get("summary", item.get("summary", title))
52
+
53
+ # Extract source/publisher - try multiple locations
54
+ source = "Unknown"
55
+ if "provider" in content and isinstance(content["provider"], dict):
56
+ source = content["provider"].get("displayName", "Unknown")
57
+ elif "publisher" in item:
58
+ source = item["publisher"]
59
+ elif "publisher" in content:
60
+ source = content["publisher"]
61
+
62
+ # Extract URL - try multiple locations
63
+ url = ""
64
+ if "canonicalUrl" in content and isinstance(content["canonicalUrl"], dict):
65
+ url = content["canonicalUrl"].get("url", "")
66
+ elif "clickThroughUrl" in content and isinstance(content["clickThroughUrl"], dict):
67
+ url = content["clickThroughUrl"].get("url", "")
68
+ elif "link" in item:
69
+ url = item["link"]
70
+
71
+ # Extract published date
72
+ try:
73
+ if "pubDate" in content:
74
+ # ISO format: "2026-01-05T12:55:00Z"
75
+ pub_str = content["pubDate"]
76
+ published = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
77
+ elif "providerPublishTime" in item:
78
+ published = datetime.fromtimestamp(item["providerPublishTime"])
79
+ else:
80
+ published = datetime.now()
81
+ except:
82
+ published = datetime.now()
83
+
84
+ article = NewsArticle(
85
+ title=title,
86
+ summary=summary,
87
+ source=source,
88
+ url=url,
89
+ published=published,
90
+ symbols=[symbol],
91
+ )
92
+ articles.append(article)
93
+
94
+ return articles
95
+ except Exception as e:
96
+ print(f"Error fetching news for {symbol}: {e}")
97
+ return []
98
+
@@ -0,0 +1,346 @@
1
+ """Stock news search across several providers, for the AI and the report.
2
+
3
+ Same environment variables as daily_stock_analysis, so one set of
4
+ secrets works for both projects:
5
+
6
+ TAVILY_API_KEYS comma-separated, rotated (tavily.com)
7
+ BRAVE_API_KEYS comma-separated, rotated (brave.com/search/api)
8
+ SERPAPI_API_KEYS comma-separated, rotated (serpapi.com, Google News)
9
+ BOCHA_API_KEYS comma-separated, rotated (bochaai.com, Chinese web)
10
+ SEARXNG_BASE_URLS your own SearXNG instance(s), no key
11
+ NEWS_MAX_AGE_DAYS how far back to look (default 3)
12
+
13
+ With none of those set it still works: Google News RSS (free, no key)
14
+ and then Yahoo Finance headlines are always available as fallbacks.
15
+ Providers are tried in the order above; the first one that returns
16
+ results wins, and a failing key is skipped on the next call.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import re
24
+ import threading
25
+ import time
26
+ import xml.etree.ElementTree as ET
27
+ from dataclasses import asdict, dataclass
28
+ from datetime import datetime, timedelta, timezone
29
+ from email.utils import parsedate_to_datetime
30
+ from functools import lru_cache
31
+ from typing import Callable, Mapping, Optional
32
+ from urllib.parse import quote_plus
33
+
34
+ import requests
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"}
39
+ _TIMEOUT = 15
40
+ _CACHE_SECONDS = 30 * 60
41
+
42
+ # Titles that are quote pages, not news.
43
+ _JUNK = re.compile(r"stock price, news, quote|share price today|live share price|stock quote|historical prices", re.I)
44
+
45
+ # Google News locale per market: (hl, gl, ceid)
46
+ _LOCALES = {
47
+ "india": ("en-IN", "IN", "IN:en"),
48
+ "us": ("en-US", "US", "US:en"),
49
+ "hong_kong": ("en-HK", "HK", "HK:en"),
50
+ "china_a": ("en-US", "US", "US:en"), # English coverage of A-shares is far better via Google News
51
+ "crypto": ("en-US", "US", "US:en"),
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class NewsItem:
57
+ title: str
58
+ source: str = ""
59
+ url: str = ""
60
+ snippet: str = ""
61
+ published: Optional[str] = None # ISO 8601
62
+ provider: str = ""
63
+
64
+ def to_dict(self) -> dict:
65
+ return asdict(self)
66
+
67
+
68
+ def _split(raw: Optional[str]) -> list[str]:
69
+ return [x.strip() for x in (raw or "").split(",") if x.strip()]
70
+
71
+
72
+ def _iso(value) -> Optional[str]:
73
+ if not value:
74
+ return None
75
+ try:
76
+ if isinstance(value, (int, float)):
77
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
78
+ text = str(value).strip()
79
+ try:
80
+ return parsedate_to_datetime(text).isoformat()
81
+ except (TypeError, ValueError):
82
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).isoformat()
83
+ except (TypeError, ValueError, OverflowError):
84
+ return None
85
+
86
+
87
+ def _fresh(item: NewsItem, days: int) -> bool:
88
+ if not item.published:
89
+ return True # unknown age: keep, providers already filter by time
90
+ try:
91
+ published = datetime.fromisoformat(item.published)
92
+ except ValueError:
93
+ return True
94
+ if published.tzinfo is None:
95
+ published = published.replace(tzinfo=timezone.utc)
96
+ return published >= datetime.now(timezone.utc) - timedelta(days=days + 1)
97
+
98
+
99
+ def _freshness(days: int, day: str, week: str, month: str, year: str) -> str:
100
+ return day if days <= 1 else week if days <= 7 else month if days <= 31 else year
101
+
102
+
103
+ # ------------------------------------------------------------- providers
104
+
105
+
106
+ def _tavily(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
107
+ resp = requests.post(
108
+ "https://api.tavily.com/search",
109
+ json={"query": query, "topic": "news", "days": days, "max_results": limit, "api_key": key},
110
+ headers={"Authorization": f"Bearer {key}"},
111
+ timeout=_TIMEOUT,
112
+ )
113
+ resp.raise_for_status()
114
+ return [
115
+ NewsItem(r.get("title", ""), source=re.sub(r"^www\.", "", (r.get("url") or "").split("/")[2] if r.get("url") else ""),
116
+ url=r.get("url", ""), snippet=r.get("content", ""), published=_iso(r.get("published_date")))
117
+ for r in resp.json().get("results", [])
118
+ ]
119
+
120
+
121
+ def _brave(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
122
+ resp = requests.get(
123
+ "https://api.search.brave.com/res/v1/news/search",
124
+ params={"q": query, "count": limit, "freshness": _freshness(days, "pd", "pw", "pm", "py")},
125
+ headers={"X-Subscription-Token": key, "Accept": "application/json"},
126
+ timeout=_TIMEOUT,
127
+ )
128
+ resp.raise_for_status()
129
+ return [
130
+ NewsItem(r.get("title", ""), source=(r.get("meta_url") or {}).get("hostname", ""), url=r.get("url", ""),
131
+ snippet=r.get("description", ""), published=_iso(r.get("page_age")))
132
+ for r in resp.json().get("results", [])
133
+ ]
134
+
135
+
136
+ def _serpapi(query: str, key: str, limit: int, days: int, market: str) -> list[NewsItem]:
137
+ hl, gl, _ = _LOCALES.get(market, _LOCALES["us"])
138
+ resp = requests.get(
139
+ "https://serpapi.com/search.json",
140
+ params={"engine": "google_news", "q": f"{query} when:{max(1, days)}d", "api_key": key,
141
+ "hl": hl.split("-")[0], "gl": gl.lower()},
142
+ timeout=_TIMEOUT,
143
+ )
144
+ resp.raise_for_status()
145
+ items = []
146
+ for r in resp.json().get("news_results", [])[:limit]:
147
+ source = r.get("source") or {}
148
+ items.append(NewsItem(r.get("title", ""), source=source.get("name", "") if isinstance(source, dict) else str(source),
149
+ url=r.get("link", ""), snippet=r.get("snippet", ""), published=_iso(r.get("iso_date") or r.get("date"))))
150
+ return items
151
+
152
+
153
+ def _bocha(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
154
+ resp = requests.post(
155
+ "https://api.bochaai.com/v1/web-search",
156
+ json={"query": query, "count": limit, "summary": True,
157
+ "freshness": _freshness(days, "oneDay", "oneWeek", "oneMonth", "oneYear")},
158
+ headers={"Authorization": f"Bearer {key}"},
159
+ timeout=_TIMEOUT,
160
+ )
161
+ resp.raise_for_status()
162
+ pages = ((resp.json().get("data") or {}).get("webPages") or {}).get("value", [])
163
+ return [
164
+ NewsItem(p.get("name", ""), source=p.get("siteName", ""), url=p.get("url", ""),
165
+ snippet=p.get("summary") or p.get("snippet", ""), published=_iso(p.get("datePublished")))
166
+ for p in pages
167
+ ]
168
+
169
+
170
+ def _searxng(query: str, base_url: str, limit: int, days: int, _market: str) -> list[NewsItem]:
171
+ resp = requests.get(
172
+ f"{base_url.rstrip('/')}/search",
173
+ params={"q": query, "format": "json", "categories": "news",
174
+ "time_range": _freshness(days, "day", "week", "month", "year")},
175
+ headers=_UA,
176
+ timeout=_TIMEOUT,
177
+ )
178
+ resp.raise_for_status()
179
+ return [
180
+ NewsItem(r.get("title", ""), source=r.get("engine", ""), url=r.get("url", ""),
181
+ snippet=r.get("content", ""), published=_iso(r.get("publishedDate")))
182
+ for r in resp.json().get("results", [])[:limit]
183
+ ]
184
+
185
+
186
+ def _google_news(query: str, _key, limit: int, days: int, market: str) -> list[NewsItem]:
187
+ hl, gl, ceid = _LOCALES.get(market, _LOCALES["us"])
188
+ url = (f"https://news.google.com/rss/search?q={quote_plus(query)}+when:{max(1, days)}d"
189
+ f"&hl={hl}&gl={gl}&ceid={ceid}")
190
+ resp = requests.get(url, headers=_UA, timeout=_TIMEOUT)
191
+ resp.raise_for_status()
192
+ items = []
193
+ for it in ET.fromstring(resp.content).iter("item"):
194
+ title = it.findtext("title") or ""
195
+ source = it.findtext("source") or ""
196
+ if source and title.endswith(f" - {source}"):
197
+ title = title[: -len(source) - 3]
198
+ items.append(NewsItem(title, source=source, url=it.findtext("link") or "", published=_iso(it.findtext("pubDate"))))
199
+ if len(items) >= limit * 3: # headroom for junk filtering
200
+ break
201
+ return items
202
+
203
+
204
+ def _yahoo(symbol_query: str, _key, limit: int, _days: int, _market: str) -> list[NewsItem]:
205
+ from .news import NewsFetcher
206
+
207
+ return [
208
+ NewsItem(a.title, source=a.source, url=a.url, snippet=a.summary if a.summary != a.title else "",
209
+ published=a.published.isoformat() if a.published else None)
210
+ for a in NewsFetcher().get_stock_news(symbol_query, limit=limit)
211
+ ]
212
+
213
+
214
+ @dataclass
215
+ class _Provider:
216
+ name: str
217
+ fn: Callable[..., list[NewsItem]]
218
+ credentials: list # keys or base URLs; [None] for keyless providers
219
+ uses_ticker: bool = False # Yahoo searches by ticker, the rest by text
220
+
221
+
222
+ class NewsSearchService:
223
+ def __init__(self, environ: Optional[Mapping[str, str]] = None):
224
+ env = os.environ if environ is None else environ
225
+ self.max_age_days = int(env.get("NEWS_MAX_AGE_DAYS") or 3)
226
+ self.providers = [
227
+ p for p in (
228
+ _Provider("tavily", _tavily, _split(env.get("TAVILY_API_KEYS") or env.get("TAVILY_API_KEY"))),
229
+ _Provider("brave", _brave, _split(env.get("BRAVE_API_KEYS") or env.get("BRAVE_API_KEY"))),
230
+ _Provider("serpapi", _serpapi, _split(env.get("SERPAPI_API_KEYS") or env.get("SERPAPI_API_KEY"))),
231
+ _Provider("bocha", _bocha, _split(env.get("BOCHA_API_KEYS") or env.get("BOCHA_API_KEY"))),
232
+ _Provider("searxng", _searxng, _split(env.get("SEARXNG_BASE_URLS"))),
233
+ _Provider("google_news", _google_news, [None]),
234
+ _Provider("yahoo", _yahoo, [None], uses_ticker=True),
235
+ ) if p.credentials
236
+ ]
237
+ self._cache: dict[tuple, tuple[float, list[NewsItem]]] = {}
238
+ self._bad_until: dict[tuple[str, int], float] = {}
239
+ self._rotation: dict[str, int] = {}
240
+ self._lock = threading.Lock()
241
+
242
+ def provider_names(self) -> list[str]:
243
+ return [p.name for p in self.providers]
244
+
245
+ def search(self, query: str, *, ticker: str = "", market: str = "us", limit: int = 5,
246
+ days: Optional[int] = None) -> list[NewsItem]:
247
+ days = days or self.max_age_days
248
+ cache_key = (query, ticker, market, limit, days)
249
+ with self._lock:
250
+ hit = self._cache.get(cache_key)
251
+ if hit and time.monotonic() - hit[0] < _CACHE_SECONDS:
252
+ return hit[1]
253
+
254
+ items: list[NewsItem] = []
255
+ for provider in self.providers:
256
+ q = ticker if provider.uses_ticker else query
257
+ if not q:
258
+ continue
259
+ start = self._rotation.get(provider.name, 0)
260
+ self._rotation[provider.name] = start + 1
261
+ creds = list(enumerate(provider.credentials))
262
+ creds = creds[start % len(creds):] + creds[: start % len(creds)]
263
+ for index, cred in creds:
264
+ if self._bad_until.get((provider.name, index), 0) > time.monotonic():
265
+ continue
266
+ try:
267
+ found = provider.fn(q, cred, limit, days, market)
268
+ except Exception as exc:
269
+ logger.warning("News provider %s #%d failed: %s", provider.name, index, str(exc)[:160])
270
+ self._bad_until[(provider.name, index)] = time.monotonic() + 10 * 60
271
+ continue
272
+ items = self._clean(found, provider.name, limit, days)
273
+ if items:
274
+ break
275
+ if items:
276
+ break
277
+
278
+ with self._lock:
279
+ self._cache[cache_key] = (time.monotonic(), items)
280
+ return items
281
+
282
+ @staticmethod
283
+ def _clean(found: list[NewsItem], provider: str, limit: int, days: int) -> list[NewsItem]:
284
+ seen, out = set(), []
285
+ for item in found:
286
+ title = " ".join((item.title or "").split())
287
+ key = re.sub(r"\W+", "", title.lower())[:80]
288
+ if not title or key in seen or _JUNK.search(title) or not _fresh(item, days):
289
+ continue
290
+ seen.add(key)
291
+ item.title, item.provider = title, provider
292
+ out.append(item)
293
+ out.sort(key=lambda i: i.published or "", reverse=True)
294
+ return out[:limit]
295
+
296
+
297
+ @lru_cache(maxsize=512)
298
+ def company_name(symbol: str, market: str) -> str:
299
+ """Best human name for a news query ("Reliance Industries" beats
300
+ "RELIANCE"). One cached Yahoo lookup per symbol."""
301
+ if market != "crypto":
302
+ try:
303
+ import yfinance as yf
304
+
305
+ from ..services.analysis_run import yahoo_symbol
306
+
307
+ info = yf.Ticker(yahoo_symbol(symbol, market)).info or {}
308
+ name = info.get("shortName") or info.get("longName")
309
+ if name:
310
+ return name
311
+ except Exception:
312
+ pass
313
+ return symbol
314
+
315
+
316
+ _SUFFIXES = re.compile(r"\b(limited|ltd\.?|inc\.?|corp(oration)?\.?|co\.?|plc|holdings?|common stock|class [a-z])\b", re.I)
317
+
318
+
319
+ def news_query(symbol: str, market: str, name: Optional[str] = None) -> str:
320
+ name = _SUFFIXES.sub("", name or company_name(symbol, market))
321
+ name = " ".join(re.sub(r"[.,()&]+", " ", name).split()).title() or symbol
322
+ if market == "crypto":
323
+ return f"{symbol.split('-')[0]} crypto"
324
+ return f"{name} share" if market == "india" else f"{name} stock"
325
+
326
+
327
+ _service: Optional[NewsSearchService] = None
328
+ _service_lock = threading.Lock()
329
+
330
+
331
+ def get_news_service() -> NewsSearchService:
332
+ global _service
333
+ with _service_lock:
334
+ if _service is None:
335
+ _service = NewsSearchService()
336
+ return _service
337
+
338
+
339
+ def stock_news(symbol: str, market: str, limit: int = 5, name: Optional[str] = None) -> list[dict]:
340
+ """Recent headlines for one stock, as plain dicts (picklable/JSON-safe)."""
341
+ from ..services.analysis_run import yahoo_symbol
342
+
343
+ service = get_news_service()
344
+ items = service.search(news_query(symbol, market, name), ticker=yahoo_symbol(symbol, market),
345
+ market=market, limit=limit)
346
+ return [i.to_dict() for i in items]
@@ -0,0 +1,138 @@
1
+ """NSE bulk and block deals: large trades by named clients (funds, promoters,
2
+ brokers) that the exchange publishes daily.
3
+
4
+ NSE's archive CSVs (the same host the listings use, which works from cloud
5
+ servers) only hold the latest trading day, so every fetch is also saved to
6
+ ~/.marketmyna/deals/<date>.json (the app data volume) and history is built
7
+ from those files. The alert monitor refreshes hourly in the evening so days
8
+ are collected even when nobody opens the page.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import io
15
+ import json
16
+ import logging
17
+ import threading
18
+ import time
19
+ from datetime import date, datetime, timedelta
20
+ from pathlib import Path
21
+ from typing import Callable, Optional
22
+
23
+ from marketmyna.core.home import app_home
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ URLS = {"bulk": "https://nsearchives.nseindia.com/content/equities/bulk.csv",
28
+ "block": "https://nsearchives.nseindia.com/content/equities/block.csv"}
29
+ CACHE_SECONDS = 1800
30
+
31
+ Fetch = Callable[[str], str]
32
+
33
+ _last: dict[str, float] = {}
34
+ _lock = threading.Lock()
35
+
36
+
37
+ def _dir() -> Path:
38
+ path = app_home() / "deals"
39
+ path.mkdir(parents=True, exist_ok=True)
40
+ return path
41
+
42
+
43
+ def _http(url: str) -> str:
44
+ import requests
45
+
46
+ resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
47
+ resp.raise_for_status()
48
+ return resp.text
49
+
50
+
51
+ def _num(v) -> Optional[float]:
52
+ try:
53
+ return float(str(v).replace(",", ""))
54
+ except (TypeError, ValueError):
55
+ return None
56
+
57
+
58
+ def parse(text: str, kind: str) -> list[dict]:
59
+ rows = []
60
+ for r in csv.DictReader(io.StringIO(text.strip())):
61
+ r = {k.strip(): (v or "").strip() for k, v in r.items() if k}
62
+ try:
63
+ day = datetime.strptime(r["Date"], "%d-%b-%Y").date().isoformat()
64
+ except (KeyError, ValueError):
65
+ continue
66
+ qty, price = _num(r.get("Quantity Traded")), _num(r.get("Trade Price / Wght. Avg. Price"))
67
+ rows.append({"date": day, "kind": kind, "symbol": r.get("Symbol", "").upper(), "name": r.get("Security Name"),
68
+ "client": r.get("Client Name"), "side": (r.get("Buy/Sell") or "").lower(), "qty": qty, "price": price,
69
+ "value": qty * price if qty and price else None,
70
+ "remarks": None if r.get("Remarks") in (None, "", "-") else r.get("Remarks")})
71
+ return rows
72
+
73
+
74
+ def refresh(fetch: Fetch = _http, force: bool = False) -> int:
75
+ """Fetch today's files (at most every 30 min) and merge them into the per-day store."""
76
+ with _lock:
77
+ if not force and time.time() - _last.get("fetch", 0) < CACHE_SECONDS:
78
+ return 0
79
+ _last["fetch"] = time.time()
80
+ fresh: list[dict] = []
81
+ for kind, url in URLS.items():
82
+ try:
83
+ fresh += parse(fetch(url), kind)
84
+ except Exception as exc:
85
+ logger.warning("NSE %s deals unavailable: %s", kind, exc)
86
+ by_day: dict[str, list[dict]] = {}
87
+ for r in fresh:
88
+ by_day.setdefault(r["date"], []).append(r)
89
+ for day, rows in by_day.items():
90
+ path = _dir() / f"{day}.json"
91
+ existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
92
+ seen = {json.dumps(r, sort_keys=True) for r in existing}
93
+ merged = existing + [r for r in rows if json.dumps(r, sort_keys=True) not in seen]
94
+ path.write_text(json.dumps(merged), encoding="utf-8")
95
+ return len(fresh)
96
+
97
+
98
+ def recent(days: int = 30, symbol: Optional[str] = None, today: Optional[date] = None, fetch: Fetch = _http) -> dict:
99
+ refresh(fetch)
100
+ today = today or date.today()
101
+ since = (today - timedelta(days=days)).isoformat()
102
+ rows: list[dict] = []
103
+ stored = sorted(p.stem for p in _dir().glob("*.json"))
104
+ for day in stored:
105
+ if day >= since:
106
+ rows += json.loads((_dir() / f"{day}.json").read_text(encoding="utf-8"))
107
+ if symbol:
108
+ rows = [r for r in rows if r["symbol"] == symbol.upper()]
109
+ rows = _merge_kinds(rows)
110
+ rows.sort(key=lambda r: (r["date"], r.get("value") or 0), reverse=True)
111
+ return {"days": days, "history_from": stored[0] if stored else None, "latest": stored[-1] if stored else None,
112
+ "count": len(rows), "deals": rows[:300]}
113
+
114
+
115
+ def _merge_kinds(rows: list[dict]) -> list[dict]:
116
+ """A block trade large enough to be a bulk deal is listed in both files:
117
+ keep one row, kind "block & bulk"."""
118
+ merged: dict[tuple, dict] = {}
119
+ for r in rows:
120
+ key = (r["date"], r["symbol"], r.get("client"), r["side"], r.get("qty"), r.get("price"))
121
+ if key in merged and merged[key]["kind"] != r["kind"]:
122
+ merged[key] = {**merged[key], "kind": "block & bulk", "remarks": merged[key].get("remarks") or r.get("remarks")}
123
+ else:
124
+ merged.setdefault(key, r)
125
+ return list(merged.values())
126
+
127
+
128
+ def maybe_refresh(now: Optional[datetime] = None) -> None:
129
+ """Alert-loop hook: NSE posts the files after the close; refresh hourly 16:00-21:59 IST."""
130
+ from zoneinfo import ZoneInfo
131
+
132
+ local = (now or datetime.now(ZoneInfo("Asia/Kolkata"))).astimezone(ZoneInfo("Asia/Kolkata"))
133
+ with _lock:
134
+ due = time.time() - _last.get("loop", 0) >= 3600
135
+ if 16 <= local.hour < 22 and local.weekday() < 5 and due:
136
+ with _lock:
137
+ _last["loop"] = time.time()
138
+ refresh(force=True)