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,397 @@
1
+ """Reddit posts about a stock.
2
+
3
+ Four ways in, tried in order until one returns posts:
4
+
5
+ 1. Official API (OAuth app-only) - only when REDDIT_CLIENT_ID/SECRET are set.
6
+ 2. Playwright on reddit.com's global search, one page load per stock.
7
+ 3. Playwright on the finance subreddits' /new feeds - fetched once and
8
+ shared by every stock for FEED_TTL seconds, then filtered by mention.
9
+ 4. The same feeds' public RSS (no scores/comment counts).
10
+
11
+ SOCIAL_REDDIT_MODE picks the order: "auto" (API first when keys exist,
12
+ else scraping), "api" (API only), "playwright" (scraping only).
13
+
14
+ Reddit rate-limits scrapers hard (a few page loads, then "blocked by network
15
+ security" for a while, sooner from cloud IPs). Each method sits behind its
16
+ own circuit breaker, so once blocked it is skipped for COOLDOWN seconds
17
+ rather than retried on every page view.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import html
23
+ import logging
24
+ import re
25
+ import threading
26
+ import time
27
+ import xml.etree.ElementTree as ET
28
+ from typing import Callable
29
+ from urllib.parse import urlencode
30
+
31
+ import requests
32
+
33
+ from ..resilience import CircuitBreaker
34
+ from . import browser
35
+ from .base import TIMEOUT, UA, SocialPost, SocialQuery, SourceBlocked, SourceSkipped, mentions, utc
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ BASE = "https://www.reddit.com"
40
+ COOLDOWN = 15 * 60
41
+ FEED_TTL = 10 * 60
42
+
43
+ SUBREDDITS = {
44
+ "us": ["wallstreetbets", "stocks", "investing", "StockMarket", "options", "ValueInvesting"],
45
+ "india": ["IndianStreetBets", "IndiaInvestments", "IndianStockMarket", "DalalStreetTalks"],
46
+ "crypto": ["CryptoCurrency", "CryptoMarkets", "Bitcoin", "ethtrader"],
47
+ }
48
+ SUBREDDITS["hong_kong"] = SUBREDDITS["china_a"] = ["stocks", "investing", "StockMarket", "ChinaStocks"]
49
+
50
+ # Global search returns every corner of Reddit; keep subreddits that are
51
+ # plausibly about markets.
52
+ _FINANCE_SUB = re.compile(r"stock|invest|trad|bets|market|financ|crypto|option|dividend|bogle|bull|bear|money|econom", re.I)
53
+
54
+ _breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=COOLDOWN)
55
+
56
+
57
+ def subreddits(market: str) -> list[str]:
58
+ return SUBREDDITS.get(market, SUBREDDITS["us"])
59
+
60
+
61
+ def _relevant_sub(community: str, market: str) -> bool:
62
+ name = community.removeprefix("r/")
63
+ return name.lower() in {s.lower() for s in subreddits(market)} or bool(_FINANCE_SUB.search(name))
64
+
65
+
66
+ def search_terms(query: SocialQuery) -> str:
67
+ terms = [query.ticker]
68
+ if query.name and query.name.upper() != query.ticker:
69
+ terms.append(f'"{query.name}"')
70
+ return " OR ".join(terms)
71
+
72
+
73
+ def _count(text: str) -> int:
74
+ """'1.2k' -> 1200, '35' -> 35."""
75
+ text = (text or "").strip().lower().replace(",", "")
76
+ match = re.match(r"([\d.]+)\s*([km]?)", text)
77
+ if not match:
78
+ return 0
79
+ value = float(match.group(1))
80
+ return int(value * {"k": 1_000, "m": 1_000_000}.get(match.group(2), 1))
81
+
82
+
83
+ # ---------------------------------------------------------------- 1. API
84
+
85
+ _token: dict = {"value": None, "expires": 0.0}
86
+ _token_lock = threading.Lock()
87
+
88
+
89
+ def _api_token(client_id: str, client_secret: str, user_agent: str) -> str:
90
+ with _token_lock:
91
+ if _token["value"] and _token["expires"] > time.time() + 60:
92
+ return _token["value"]
93
+ r = requests.post(
94
+ f"{BASE}/api/v1/access_token",
95
+ auth=(client_id, client_secret),
96
+ data={"grant_type": "client_credentials"},
97
+ headers={"User-Agent": user_agent},
98
+ timeout=TIMEOUT,
99
+ )
100
+ if r.status_code in (401, 403):
101
+ raise SourceSkipped("Reddit rejected the API keys")
102
+ r.raise_for_status()
103
+ data = r.json()
104
+ _token.update(value=data["access_token"], expires=time.time() + float(data.get("expires_in", 3600)))
105
+ return _token["value"]
106
+
107
+
108
+ def parse_listing(payload: dict) -> list[SocialPost]:
109
+ """Reddit JSON listing (API or .json) -> posts."""
110
+ posts = []
111
+ for child in (payload.get("data") or {}).get("children") or []:
112
+ d = child.get("data") or {}
113
+ if not d.get("id") or not d.get("title"):
114
+ continue
115
+ posts.append(
116
+ SocialPost(
117
+ source="reddit",
118
+ id=f"reddit:{d['id']}",
119
+ title=html.unescape(d["title"]),
120
+ body=html.unescape(d.get("selftext") or "")[:2000],
121
+ url=BASE + d.get("permalink", ""),
122
+ created_at=utc(float(d.get("created_utc") or 0)),
123
+ author=d.get("author") or "",
124
+ community=f"r/{d.get('subreddit', '')}",
125
+ score=int(d.get("score") or 0),
126
+ comments=int(d.get("num_comments") or 0),
127
+ )
128
+ )
129
+ return posts
130
+
131
+
132
+ def fetch_api(query: SocialQuery, client_id: str, client_secret: str, user_agent: str = "") -> list[SocialPost]:
133
+ user_agent = user_agent or "MarketMyna/1.0 (social sentiment)"
134
+ token = _api_token(client_id, client_secret, user_agent)
135
+ subs = "+".join(subreddits(query.market))
136
+ params = {"q": search_terms(query), "restrict_sr": 1, "sort": "new", "t": "week", "limit": 100}
137
+ r = requests.get(
138
+ f"https://oauth.reddit.com/r/{subs}/search",
139
+ params=params,
140
+ headers={"Authorization": f"bearer {token}", "User-Agent": user_agent},
141
+ timeout=TIMEOUT,
142
+ )
143
+ if r.status_code == 429:
144
+ raise SourceBlocked("Reddit API rate limit")
145
+ r.raise_for_status()
146
+ return parse_listing(r.json())
147
+
148
+
149
+ # ------------------------------------------------------ 2. browser search
150
+
151
+ _SEARCH_JS = r"""
152
+ () => [...document.querySelectorAll('[data-testid="search-post-unit"]')].map(u => {
153
+ const a = u.querySelector('a[data-testid="post-title"]');
154
+ const sub = [...u.querySelectorAll('a[href^="/r/"]')].find(x => !x.dataset.testid);
155
+ const t = u.querySelector('faceplate-timeago');
156
+ return {title: a && (a.getAttribute('aria-label') || a.innerText), href: a && a.getAttribute('href'),
157
+ sub: sub ? sub.innerText.trim() : '', ts: t ? t.getAttribute('ts') : null, text: u.innerText};
158
+ })
159
+ """
160
+
161
+
162
+ def parse_search_units(rows: list[dict]) -> list[SocialPost]:
163
+ posts = []
164
+ for row in rows:
165
+ href, title = row.get("href") or "", (row.get("title") or "").strip()
166
+ match = re.search(r"/comments/([a-z0-9]+)/", href)
167
+ if not match or not title or not row.get("ts"):
168
+ continue
169
+ text = row.get("text") or ""
170
+ votes = re.search(r"([\d.,]+[kKmM]?)\s+votes?", text)
171
+ comments = re.search(r"([\d.,]+[kKmM]?)\s+comments?", text)
172
+ community = (row.get("sub") or "").strip()
173
+ if not community:
174
+ sub = re.match(r"/r/([^/]+)/", href)
175
+ community = f"r/{sub.group(1)}" if sub else ""
176
+ posts.append(
177
+ SocialPost(
178
+ source="reddit",
179
+ id=f"reddit:{match.group(1)}",
180
+ title=title,
181
+ url=BASE + href,
182
+ created_at=utc(row["ts"]),
183
+ community=community,
184
+ score=_count(votes.group(1)) if votes else 0,
185
+ comments=_count(comments.group(1)) if comments else 0,
186
+ )
187
+ )
188
+ return posts
189
+
190
+
191
+ def fetch_browser_search(query: SocialQuery) -> list[SocialPost]:
192
+ url = f"{BASE}/search/?" + urlencode({"q": search_terms(query), "type": "posts", "sort": "new", "t": "week"})
193
+ with browser.page() as pg:
194
+ response = pg.goto(url, wait_until="domcontentloaded")
195
+ browser.check_blocked(pg, response.status if response else None)
196
+ try:
197
+ pg.wait_for_selector('[data-testid="search-post-unit"]', timeout=6000)
198
+ except Exception:
199
+ browser.check_blocked(pg)
200
+ return [] # genuinely no results
201
+ pg.mouse.wheel(0, 6000)
202
+ pg.wait_for_timeout(1200)
203
+ rows = pg.evaluate(_SEARCH_JS)
204
+ return [p for p in parse_search_units(rows) if _relevant_sub(p.community, query.market)]
205
+
206
+
207
+ # ------------------------------------------------- 3/4. shared feed pool
208
+
209
+ _FEED_JS = r"""
210
+ () => [...document.querySelectorAll('shreddit-post')].map(p => {
211
+ const body = p.querySelector('[slot="text-body"]');
212
+ return {id: p.getAttribute('id'), title: p.getAttribute('post-title'), permalink: p.getAttribute('permalink'),
213
+ score: p.getAttribute('score'), comments: p.getAttribute('comment-count'),
214
+ ts: p.getAttribute('created-timestamp'), author: p.getAttribute('author'),
215
+ sub: p.getAttribute('subreddit-prefixed-name'), body: body ? body.innerText : ''};
216
+ })
217
+ """
218
+
219
+
220
+ def parse_feed_posts(rows: list[dict]) -> list[SocialPost]:
221
+ posts = []
222
+ for row in rows:
223
+ if not row.get("id") or not row.get("title") or not row.get("ts"):
224
+ continue
225
+ posts.append(
226
+ SocialPost(
227
+ source="reddit",
228
+ id=f"reddit:{row['id'].removeprefix('t3_')}",
229
+ title=row["title"],
230
+ body=(row.get("body") or "")[:2000],
231
+ url=BASE + (row.get("permalink") or ""),
232
+ created_at=utc(row["ts"]),
233
+ author=row.get("author") or "",
234
+ community=row.get("sub") or "",
235
+ score=_count(row.get("score") or "0"),
236
+ comments=_count(row.get("comments") or "0"),
237
+ )
238
+ )
239
+ return posts
240
+
241
+
242
+ _ATOM = {"a": "http://www.w3.org/2005/Atom"}
243
+
244
+
245
+ def parse_rss(xml_text: str, subreddit: str) -> list[SocialPost]:
246
+ posts = []
247
+ root = ET.fromstring(xml_text)
248
+ for entry in root.findall("a:entry", _ATOM):
249
+ title = entry.findtext("a:title", default="", namespaces=_ATOM)
250
+ link = entry.find("a:link", _ATOM)
251
+ url = link.get("href") if link is not None else ""
252
+ match = re.search(r"/comments/([a-z0-9]+)/", url or "")
253
+ stamp = entry.findtext("a:published", namespaces=_ATOM) or entry.findtext("a:updated", namespaces=_ATOM)
254
+ if not match or not title or not stamp:
255
+ continue
256
+ content = entry.findtext("a:content", default="", namespaces=_ATOM)
257
+ body = html.unescape(re.sub(r"<[^>]+>", " ", html.unescape(content)))
258
+ body = re.sub(r"submitted by\s+/u/\S+.*$", "", " ".join(body.split())).strip()
259
+ posts.append(
260
+ SocialPost(
261
+ source="reddit",
262
+ id=f"reddit:{match.group(1)}",
263
+ title=html.unescape(title),
264
+ body=body[:2000],
265
+ url=url,
266
+ created_at=utc(stamp),
267
+ author=(entry.findtext("a:author/a:name", default="", namespaces=_ATOM) or "").removeprefix("/u/"),
268
+ community=f"r/{subreddit}",
269
+ )
270
+ )
271
+ return posts
272
+
273
+
274
+ class _FeedPool:
275
+ """Recent posts of the market's subreddits, shared across stocks."""
276
+
277
+ def __init__(self):
278
+ self._data: dict[str, tuple[float, list[SocialPost]]] = {}
279
+ self._lock = threading.Lock()
280
+
281
+ def get(self, key: str, loader: Callable[[], list[SocialPost]]) -> list[SocialPost]:
282
+ with self._lock:
283
+ hit = self._data.get(key)
284
+ if hit and time.time() - hit[0] < FEED_TTL:
285
+ return hit[1]
286
+ posts = loader()
287
+ self._data[key] = (time.time(), posts)
288
+ return posts
289
+
290
+
291
+ _pool = _FeedPool()
292
+
293
+
294
+ def _load_feeds_browser(market: str) -> list[SocialPost]:
295
+ posts: list[SocialPost] = []
296
+ with browser.page() as pg:
297
+ for sub in subreddits(market):
298
+ response = pg.goto(f"{BASE}/r/{sub}/new/", wait_until="domcontentloaded")
299
+ browser.check_blocked(pg, response.status if response else None)
300
+ pg.mouse.wheel(0, 8000)
301
+ pg.wait_for_timeout(1000)
302
+ posts.extend(parse_feed_posts(pg.evaluate(_FEED_JS)))
303
+ pg.wait_for_timeout(800) # pace: one subreddit at a time
304
+ return posts
305
+
306
+
307
+ def _load_feeds_rss(market: str) -> list[SocialPost]:
308
+ posts: list[SocialPost] = []
309
+ for sub in subreddits(market):
310
+ r = requests.get(f"{BASE}/r/{sub}/new/.rss", params={"limit": 100}, headers={"User-Agent": UA}, timeout=TIMEOUT)
311
+ if r.status_code in (403, 429):
312
+ if posts:
313
+ break # keep what we have
314
+ raise SourceBlocked(f"Reddit RSS HTTP {r.status_code}")
315
+ r.raise_for_status()
316
+ posts.extend(parse_rss(r.text, sub))
317
+ time.sleep(0.5)
318
+ return posts
319
+
320
+
321
+ def fetch_browser_feeds(query: SocialQuery) -> list[SocialPost]:
322
+ posts = _pool.get(f"browser:{query.market}", lambda: _load_feeds_browser(query.market))
323
+ return [p for p in posts if mentions(p, query)]
324
+
325
+
326
+ def fetch_rss_feeds(query: SocialQuery) -> list[SocialPost]:
327
+ posts = _pool.get(f"rss:{query.market}", lambda: _load_feeds_rss(query.market))
328
+ return [p for p in posts if mentions(p, query)]
329
+
330
+
331
+ # ------------------------------------------------------------- dispatcher
332
+
333
+
334
+ def methods(config) -> list[tuple[str, Callable[[SocialQuery], list[SocialPost]]]]:
335
+ """(name, fetcher) in the order SOCIAL_REDDIT_MODE asks for."""
336
+ mode = (getattr(config, "social_reddit_mode", None) or "auto").lower()
337
+ client_id = getattr(config, "reddit_client_id", None)
338
+ secret = getattr(config, "reddit_client_secret", None)
339
+ api: list = []
340
+ if client_id and secret:
341
+ ua = getattr(config, "reddit_user_agent", None) or ""
342
+ api = [("api", lambda q: fetch_api(q, client_id, secret, ua))]
343
+ scrape = [("search", fetch_browser_search), ("feeds", fetch_browser_feeds), ("rss", fetch_rss_feeds)]
344
+ if not getattr(config, "social_playwright", True):
345
+ scrape = [("rss", fetch_rss_feeds)] # headless browser switched off
346
+ if mode == "api":
347
+ return api
348
+ if mode == "playwright":
349
+ return scrape
350
+ return api + scrape
351
+
352
+
353
+ def fetch(query: SocialQuery, config=None) -> tuple[list[SocialPost], str]:
354
+ """Posts plus a note on how they were fetched ("search", "feeds", ...).
355
+
356
+ Raises SourceBlocked/SourceSkipped when no method produced anything."""
357
+ chain = methods(config)
358
+ if not chain:
359
+ raise SourceSkipped("Reddit API mode is selected but no API keys are set")
360
+ errors: list[str] = []
361
+ answered = None # first method that worked but found nothing
362
+ for name, fetcher in chain:
363
+ key = f"reddit:{name}"
364
+ if not _breaker.is_available(key):
365
+ errors.append(f"{name}: backing off after a block")
366
+ continue
367
+ try:
368
+ posts = [p for p in fetcher(query) if mentions(p, query)]
369
+ except SourceSkipped as exc:
370
+ errors.append(f"{name}: {exc}")
371
+ continue
372
+ except SourceBlocked as exc:
373
+ _breaker.record_failure(key, str(exc))
374
+ errors.append(f"{name}: blocked ({exc})")
375
+ logger.info("Reddit %s blocked: %s", name, exc)
376
+ continue
377
+ except Exception as exc: # network, layout change, ...
378
+ _breaker.record_failure(key, str(exc))
379
+ errors.append(f"{name}: {exc}")
380
+ logger.warning("Reddit %s failed: %s", name, exc)
381
+ continue
382
+ _breaker.record_success(key)
383
+ if posts:
384
+ return posts, name
385
+ answered = answered or name
386
+ if answered:
387
+ return [], answered
388
+ raise SourceBlocked("; ".join(errors))
389
+
390
+
391
+
392
+ def reset() -> None:
393
+ """Tests: forget caches, tokens and breaker state."""
394
+ global _breaker, _pool
395
+ _breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=COOLDOWN)
396
+ _pool = _FeedPool()
397
+ _token.update(value=None, expires=0.0)
@@ -0,0 +1,63 @@
1
+ """StockTwits stream for a symbol (US stocks and crypto).
2
+
3
+ The public JSON stream sits behind Cloudflare, which turns away plain HTTP
4
+ clients, so it is read through the headless browser. Many messages carry
5
+ an author-set Bullish/Bearish tag - the most direct sentiment signal of
6
+ any source, used as-is by the scorer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from . import browser
14
+ from .base import SocialPost, SocialQuery, SourceBlocked, SourceSkipped, utc
15
+
16
+ STREAM = "https://api.stocktwits.com/api/2/streams/symbol/{symbol}.json"
17
+
18
+
19
+ def stocktwits_symbol(query: SocialQuery) -> str:
20
+ if query.market == "crypto":
21
+ return f"{query.ticker}.X" # BTC-USD -> BTC.X
22
+ if query.market == "us":
23
+ return query.symbol.upper().replace("-", ".") # BRK-B -> BRK.B
24
+ raise SourceSkipped("StockTwits covers US stocks and crypto")
25
+
26
+
27
+ def parse_stream(payload: dict) -> list[SocialPost]:
28
+ posts = []
29
+ for m in payload.get("messages") or []:
30
+ if not m.get("id") or not m.get("body"):
31
+ continue
32
+ user = (m.get("user") or {}).get("username") or ""
33
+ basic = ((m.get("entities") or {}).get("sentiment") or {}).get("basic")
34
+ posts.append(
35
+ SocialPost(
36
+ source="stocktwits",
37
+ id=f"stocktwits:{m['id']}",
38
+ title=m["body"][:280],
39
+ body=m["body"][280:2000],
40
+ url=f"https://stocktwits.com/{user}/message/{m['id']}",
41
+ created_at=utc(m["created_at"]),
42
+ author=user,
43
+ community="StockTwits",
44
+ score=int((m.get("likes") or {}).get("total") or 0),
45
+ comments=int((m.get("conversation") or {}).get("replies") or 0),
46
+ label=basic.lower() if basic in ("Bullish", "Bearish") else None,
47
+ )
48
+ )
49
+ return posts
50
+
51
+
52
+ def fetch(query: SocialQuery, config=None) -> list[SocialPost]:
53
+ if not getattr(config, "social_playwright", True):
54
+ raise SourceSkipped("Headless-browser scraping is switched off")
55
+ text = browser.get_text(STREAM.format(symbol=stocktwits_symbol(query)))
56
+ try:
57
+ payload = json.loads(text)
58
+ except json.JSONDecodeError as exc:
59
+ raise SourceBlocked("StockTwits did not return JSON") from exc
60
+ status = (payload.get("response") or {}).get("status")
61
+ if status == 404:
62
+ raise SourceSkipped("Symbol not on StockTwits")
63
+ return parse_stream(payload)
@@ -0,0 +1,53 @@
1
+ """ValuePickr (forum.valuepickr.com) - India's main stock-discussion forum.
2
+
3
+ Runs on Discourse, whose search answers plain JSON, so no browser needed.
4
+ Posts are slower and longer than Reddit's, so the window is wider.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ import re
11
+
12
+ import requests
13
+
14
+ from .base import TIMEOUT, UA, SocialPost, SocialQuery, SourceBlocked, SourceSkipped, utc
15
+
16
+ BASE = "https://forum.valuepickr.com"
17
+
18
+
19
+ def parse_search(payload: dict) -> list[SocialPost]:
20
+ topics = {t["id"]: t for t in payload.get("topics") or [] if t.get("id")}
21
+ posts = []
22
+ for p in payload.get("posts") or []:
23
+ topic = topics.get(p.get("topic_id")) or {}
24
+ if not p.get("id") or not topic.get("title"):
25
+ continue
26
+ posts.append(
27
+ SocialPost(
28
+ source="valuepickr",
29
+ id=f"valuepickr:{p['id']}",
30
+ title=html.unescape(topic["title"]),
31
+ body=html.unescape(re.sub(r"\s+", " ", p.get("blurb") or "")).strip("."),
32
+ url=f"{BASE}/t/{topic.get('slug', 'topic')}/{p['topic_id']}/{p.get('post_number', 1)}",
33
+ created_at=utc(p["created_at"]),
34
+ author=p.get("username") or "",
35
+ community="ValuePickr",
36
+ score=int(p.get("like_count") or 0),
37
+ comments=int(topic.get("reply_count") or 0) if p.get("post_number") == 1 else 0,
38
+ )
39
+ )
40
+ return posts
41
+
42
+
43
+ def fetch(query: SocialQuery, config=None) -> list[SocialPost]:
44
+ if query.market != "india":
45
+ raise SourceSkipped("ValuePickr covers Indian stocks")
46
+ term = query.name or query.ticker
47
+ r = requests.get(
48
+ f"{BASE}/search.json", params={"q": f"{term} order:latest"}, headers={"User-Agent": UA}, timeout=TIMEOUT
49
+ )
50
+ if r.status_code in (403, 429):
51
+ raise SourceBlocked(f"ValuePickr HTTP {r.status_code}")
52
+ r.raise_for_status()
53
+ return parse_search(r.json())