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,283 @@
1
+ """Shariah (halal) screening for listings.
2
+
3
+ Two layers:
4
+
5
+ 1. ``screen_row`` - a fast, per-listing check run on every row when the
6
+ listing cache loads. Stocks get a *business-activity* screen (what the
7
+ company does) from its sector / industry / name; funds get a *mandate*
8
+ screen (is it a Shariah-screened fund at all). This is what the Markets
9
+ "Shariah" filter uses.
10
+ 2. ``marketmyna/services/shariah.py`` - the full per-stock profile (business
11
+ screen + balance-sheet ratios + purification), computed on demand.
12
+
13
+ Statuses:
14
+ halal - passes the screen (for stocks: business activity only -
15
+ the financial ratios are checked on the stock page)
16
+ doubtful - mixed or unclear activity (hotels, media, REITs, ...)
17
+ haram - prohibited activity (interest-based finance, alcohol,
18
+ tobacco, gambling, pork, weapons, adult content, ...) or an
19
+ interest-bearing / conventional fund
20
+ unknown - not enough information to say
21
+
22
+ This is a screening aid, not a fatwa - scholars and index providers
23
+ (AAOIFI, S&P / Dow Jones Islamic, MSCI, FTSE) differ at the edges.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from typing import Optional
30
+
31
+ HALAL = "halal"
32
+ DOUBTFUL = "doubtful"
33
+ HARAM = "haram"
34
+ UNKNOWN = "unknown"
35
+
36
+ STATUSES = (HALAL, DOUBTFUL, HARAM, UNKNOWN)
37
+
38
+ # ---------------------------------------------------------------- funds
39
+
40
+ # Known Shariah-screened funds/ETFs whose names don't say so.
41
+ SHARIAH_TICKERS = {
42
+ # SP Funds (all Shariah-screened by S&P / Dow Jones)
43
+ "SPUS", "SPSK", "SPRE", "SPTE", "SPWO",
44
+ # Wahed, Manzil
45
+ "HLAL", "UMMA", "MNZL", "KWIN",
46
+ # Amana, Iman, Azzad (US mutual funds)
47
+ "AMAGX", "AMANX", "AMAPX", "AMIGX", "AMINX", "AMIDX", "IMANX", "ADJEX", "WISEX",
48
+ # India
49
+ "SHARIABEES",
50
+ }
51
+
52
+ _SHARIAH_NAME = re.compile(
53
+ r"shari'?a[h]?|islamic|\bhalal\b|\bsukuk\b|\bamana\b|\bwahed\b|\bmanzil\b|\biman fund\b|\bazzad\b|"
54
+ r"\bsp funds\b|\bfalah\b",
55
+ re.I,
56
+ )
57
+ # In India every "Ethical" fund (Tata, Taurus, Quantum, The Wealth Company)
58
+ # benchmarks a Shariah index and follows Shariah screening. Elsewhere
59
+ # "ethical" usually means ESG, which still allows banks and interest.
60
+ _INDIA_ETHICAL = re.compile(r"\bethical\b", re.I)
61
+
62
+ _INTEREST_FUND = re.compile(
63
+ r"\b(liquid|money market|overnight|gilt|g-?sec|treasury|t-?bill|bonds?|debt|income|credit|"
64
+ r"corporate bond|banking (and|&) psu|floater|floating rate|duration|fixed (maturity|income)|"
65
+ r"fmp|arbitrage|sdl|bharat bond|aggregate|municipal|muni|high yield|mortgage)\b",
66
+ re.I,
67
+ )
68
+
69
+
70
+ def _fund(row: dict) -> tuple[str, str]:
71
+ name = row.get("name") or ""
72
+ symbol = (row.get("symbol") or "").upper()
73
+ if symbol in SHARIAH_TICKERS or _SHARIAH_NAME.search(name):
74
+ if re.search(r"\bsukuk\b", name, re.I) or symbol in ("SPSK", "KWIN"):
75
+ return HALAL, "Ethical bond fund - asset-backed, no interest"
76
+ return HALAL, "Ethically screened fund - excludes interest-based finance and excluded businesses"
77
+ if row.get("market") == "india" and _INDIA_ETHICAL.search(name):
78
+ return HALAL, "Indian 'Ethical' fund - screened against an ethical index"
79
+
80
+ category = row.get("category") or ""
81
+ text = f"{name} {row.get('fund_group') or ''} {row.get('sector') or ''}"
82
+ if category in ("Debt", "Bonds") or _INTEREST_FUND.search(text):
83
+ return HARAM, "Holds interest-bearing debt"
84
+ if category == "Leveraged & Inverse":
85
+ return HARAM, "Uses leverage / derivatives"
86
+ if category == "Hybrid" or re.search(r"\bhybrid|balanced|asset allocat|multi asset|equity savings\b", text, re.I):
87
+ return HARAM, "Hybrid fund - part of the money sits in interest-bearing debt"
88
+ if re.search(r"\b(gold|silver)\b", text, re.I) and not re.search(r"miners?|mining|futures|equity|stocks", text, re.I):
89
+ return DOUBTFUL, "Precious-metal fund - passes some screens only if physically backed"
90
+ if category == "Commodities":
91
+ return HARAM, "Commodity futures (speculation and interest on collateral)"
92
+ return HARAM, "No ethical mandate - a conventional fund holds banks, insurers and interest-bearing assets"
93
+
94
+
95
+ # --------------------------------------------------------------- stocks
96
+
97
+ # Prohibited core business. Checked against the company name and industry.
98
+ _HARAM_WORDS = re.compile(
99
+ r"\b(banks?|banking|bancorp\w*|bancshares|bankshares|banc|financ(e|ial|iers?|ing)|fincorp|finserv|fincap|"
100
+ r"finvest|credit|lending|loans?|insurance|insurers?|assurance|reinsurance|nbfc|micro ?finance|"
101
+ r"mortgage|securities|broking|stock ?brokers?|"
102
+ r"brew(ery|eries|ers|ing)|distiller(y|ies|s)?|wines?|winery|wineries|spirits|liquors?|beers?|"
103
+ r"whisk(e)?y|vodka|alcohol\w*|"
104
+ r"tobacco|cigarettes?|cigars?|vap(e|ing)|"
105
+ r"casinos?|gambling|betting|lotter(y|ies)|wagering|bookmakers?|sportsbook|"
106
+ r"pork|swine|hogs?|"
107
+ r"adult entertainment|pornograph\w*|"
108
+ r"firearms?|ammunition|munitions?|ordnance|guns?)\b",
109
+ re.I,
110
+ )
111
+ # Gambling, alcohol and tobacco companies whose names don't say so.
112
+ _HARAM_BRANDS = re.compile(
113
+ r"\b(las vegas sands|sands china|wynn|melco|sjm holdings|galaxy ent\w*|mgm (resorts|china)|caesars|"
114
+ r"draftkings|flutter|churchill downs|penn entertainment|boyd gaming|red rock resorts|monarch casino|"
115
+ r"delta corp|"
116
+ r"itc limited|godfrey phillips|vst industries|altria|philip morris|british american tobacco|turning point brands|"
117
+ r"radico khaitan|tilaknagar|sula vineyards|constellation brands|molson coors|boston beer|brown-forman|"
118
+ r"diageo|anheuser|heineken|pernod|carlsberg|ambev|tsingtao|budweiser|"
119
+ r"hsbc|stanchart|standard chartered|boc hong kong|ping an|china life|prudential|berkshire hathaway|"
120
+ r"allianz|manulife|sun life)\b",
121
+ re.I,
122
+ )
123
+ _ISLAMIC_FINANCE = re.compile(r"islamic|shari'?a|takaful|sukuk", re.I)
124
+ # Chinese names (China A / Hong Kong): banks, brokers, insurers, trusts,
125
+ # futures, finance, alcohol, tobacco, lottery / gaming.
126
+ _HARAM_ZH = ("银行", "证券", "保险", "信托", "期货", "金融", "租赁", "酒业", "酒厂", "啤酒", "白酒", "葡萄酒", "烟草", "彩票", "博彩",
127
+ "人寿", "中国平安", "茅台", "五粮液", "老窖", "汾酒", "洋河", "古井贡", "酒鬼")
128
+ _DOUBTFUL_ZH = ("酒店", "影视", "传媒", "娱乐", "游戏", "军工", "兵装")
129
+
130
+ # Mixed or unclear activity.
131
+ _DOUBTFUL_WORDS = re.compile(
132
+ r"\b(hotels?|resorts?|hospitality|restaurants?|bars?|pubs?|night ?clubs?|cinemas?|multiplex|films?|"
133
+ r"studios?|movies?|music|entertainment|broadcast\w*|media|television|advertising|"
134
+ r"defen[cs]e|military|weapons?|explosives?|aerospace (and|&) defen[cs]e|"
135
+ r"leasing|reit|meat|poultry|beverages?)\b",
136
+ re.I,
137
+ )
138
+ # Name words that often mean a finance / holding company, but only matter
139
+ # when nothing else classifies the business ("XYZ Holdings", "ABC Capital").
140
+ _VAGUE_FINANCE_WORDS = re.compile(r"\b(capital|holdings?|invest\w*|trusts?|ventures)\b", re.I)
141
+
142
+ # Exact US (NASDAQ screener) industries.
143
+ _US_HARAM_INDUSTRY = {
144
+ "major banks", "commercial banks", "savings institutions", "banks", "finance: consumer services",
145
+ "finance companies", "investment bankers/brokers/service", "investment managers",
146
+ "finance/investors services", "property-casualty insurers", "life insurance", "specialty insurers",
147
+ "accident &health insurance", "diversified financial services", "blank checks", "tobacco",
148
+ "ordnance and accessories",
149
+ }
150
+ _US_DOUBTFUL_INDUSTRY = {
151
+ "services-misc. amusement & recreation", "hotels/resorts", "restaurants", "movies/entertainment",
152
+ "broadcasting", "cable & other pay television services", "advertising", "military/government/technical",
153
+ "real estate investment trusts", "trusts except educational religious and charitable",
154
+ "meat/poultry/fish", "rental/leasing companies", "misc corporate leasing services",
155
+ "beverages (production/distribution)", "managed health care", "real estate",
156
+ }
157
+ _INDIA_SECTOR = {
158
+ "financial services": (HARAM, "Conventional financial services - interest-based"),
159
+ "media entertainment & publication": (DOUBTFUL, "Media & entertainment - content may include prohibited material"),
160
+ }
161
+
162
+
163
+ def _stock(row: dict) -> tuple[str, str]:
164
+ name = row.get("name") or ""
165
+ local = row.get("name_local") or ""
166
+ sector = (row.get("sector") or "").strip()
167
+ industry = (row.get("industry") or "").strip()
168
+ text = f"{name} {industry}"
169
+
170
+ if _ISLAMIC_FINANCE.search(name):
171
+ return DOUBTFUL, "Ethical-finance company - verify its ethics board certification"
172
+ if _HARAM_BRANDS.search(name):
173
+ return HARAM, "Excluded core business (interest-based finance, gambling, alcohol or tobacco)"
174
+ hit = _HARAM_WORDS.search(text)
175
+ if hit:
176
+ return HARAM, f"Excluded business activity ({hit.group(0).lower()})"
177
+ if sector == "REIT" or re.search(r"\breit\b", name, re.I):
178
+ return DOUBTFUL, "REIT - passes only if its tenants and financing pass the ethical screen"
179
+ if sector == "InvIT":
180
+ return DOUBTFUL, "InvIT - roads and power assets pass the screen, but these trusts usually carry interest-bearing debt"
181
+ zh = next((w for w in _HARAM_ZH if w in local or w in name or w in sector), None)
182
+ if zh:
183
+ return HARAM, f"Excluded business activity ({zh})"
184
+
185
+ ind = industry.lower()
186
+ if ind in _US_HARAM_INDUSTRY:
187
+ return HARAM, f"Excluded business activity ({industry})"
188
+ if sector.lower() == "finance" and ind not in _US_DOUBTFUL_INDUSTRY:
189
+ return HARAM, "Conventional financial services - interest-based"
190
+ if ind in _US_DOUBTFUL_INDUSTRY:
191
+ return DOUBTFUL, f"Mixed activity ({industry}) - check revenue sources"
192
+ mapped = _INDIA_SECTOR.get(sector.lower())
193
+ if mapped:
194
+ return mapped
195
+
196
+ doubt = _DOUBTFUL_WORDS.search(text)
197
+ if doubt:
198
+ return DOUBTFUL, f"Mixed or unclear activity ({doubt.group(0).lower()}) - check revenue sources"
199
+ zh = next((w for w in _DOUBTFUL_ZH if w in local or w in name or w in sector), None)
200
+ if zh:
201
+ return DOUBTFUL, f"Mixed or unclear activity ({zh}) - check revenue sources"
202
+
203
+ category = row.get("category")
204
+ unclassified = not sector and not industry
205
+ vague = _VAGUE_FINANCE_WORDS.search(name)
206
+ if unclassified and vague:
207
+ return DOUBTFUL, f"Name suggests a finance or holding company ({vague.group(0).lower()}) - verify"
208
+ if category == "Financials":
209
+ return DOUBTFUL, "Looks like a financial company - verify"
210
+ if not category or category == "Other":
211
+ return UNKNOWN, "Business not classified - open the stock for a full screen"
212
+ how = "guessed from the company name" if row.get("category_inferred") else "sector"
213
+ return HALAL, f"Business activity passes the ethical screen ({category}, {how}) - financial ratios checked on the stock page"
214
+
215
+
216
+ def screen_row(row: dict) -> tuple[str, str]:
217
+ """(status, reason) for one listing row."""
218
+ type_ = row.get("type")
219
+ # Some exchanges list ETFs alongside stocks.
220
+ if type_ in ("mf", "etf") or re.search(r"\b(etf|bees)\b", row.get("name") or "", re.I):
221
+ return _fund(row)
222
+ if type_ == "crypto":
223
+ return UNKNOWN, "Crypto - not covered by the ethical screen"
224
+ return _stock(row)
225
+
226
+
227
+ def stamp(rows: list[dict]) -> list[dict]:
228
+ """Stamp ``shariah`` + ``shariah_reason`` on every row in place."""
229
+ for row in rows:
230
+ row["shariah"], row["shariah_reason"] = screen_row(row)
231
+ return rows
232
+
233
+
234
+ def business_status(name: str, sector: Optional[str], industry: Optional[str], summary: str = "") -> tuple[str, str, list[str]]:
235
+ """Business screen for the full profile, from Yahoo sector/industry.
236
+
237
+ Returns (status, reason, flags) where flags are prohibited activities
238
+ mentioned in the business description."""
239
+ ind = (industry or "").lower()
240
+ yahoo_haram = (
241
+ "banks", "credit services", "capital markets", "mortgage finance", "insurance", "asset management",
242
+ "financial conglomerates", "brewers", "wineries", "distilleries", "tobacco", "gambling",
243
+ "resorts & casinos", "shell companies",
244
+ )
245
+ yahoo_doubtful = (
246
+ "aerospace & defense", "entertainment", "lodging", "restaurants", "broadcasting", "advertising",
247
+ "reit", "leisure", "financial data & stock exchanges", "beverages", "farm products", "packaged foods",
248
+ )
249
+
250
+ flags = sorted({m.group(0).lower() for m in _SUMMARY_FLAGS.finditer(summary or "")})
251
+
252
+ if _ISLAMIC_FINANCE.search(name):
253
+ return DOUBTFUL, "Ethical-finance company - verify its ethics board certification", flags
254
+ if _HARAM_BRANDS.search(name):
255
+ return HARAM, "Excluded core business (interest-based finance, gambling, alcohol or tobacco)", flags
256
+ hit = next((k for k in yahoo_haram if k in ind), None)
257
+ if hit and not (hit == "beverages" and "non-alcoholic" in ind):
258
+ return HARAM, f"Excluded core business ({industry})", flags
259
+ if (sector or "").lower() == "financial services":
260
+ return HARAM, f"Conventional financial services ({industry or sector})", flags
261
+ doubt = next((k for k in yahoo_doubtful if k in ind), None)
262
+ if doubt and not ("non-alcoholic" in ind and doubt == "beverages"):
263
+ # Packaged food / farm products only matter if pork or alcohol shows up.
264
+ if doubt in ("farm products", "packaged foods", "beverages") and not flags:
265
+ pass
266
+ else:
267
+ return DOUBTFUL, f"Mixed activity ({industry}) - check revenue sources", flags
268
+ if not industry and not sector:
269
+ status, reason = _stock({"name": name, "type": "stock"})
270
+ return status, reason, flags
271
+ if flags:
272
+ return DOUBTFUL, "Business description mentions: " + ", ".join(flags), flags
273
+ return HALAL, f"Core business passes the ethical screen ({industry or sector})", flags
274
+
275
+
276
+ # Prohibited activities worth flagging when they appear in the business
277
+ # description (e.g. a hotel chain that sells alcohol, a food group with pork).
278
+ _SUMMARY_FLAGS = re.compile(
279
+ r"\b(casinos?|gambling|betting|lotter(y|ies)|wagering|alcoholic|beer|wines?|spirits|liquor|brewing|"
280
+ r"distill\w*|pork|tobacco|cigarettes?|adult entertainment|pornograph\w*|firearms|ammunition|"
281
+ r"weapons|idols?|astrology|conventional (banking|insurance))\b",
282
+ re.I,
283
+ )
@@ -0,0 +1,9 @@
1
+ """Social-media / forum posts about a stock, for the sentiment section.
2
+
3
+ Sources: reddit (API or Playwright), stocktwits (Playwright), valuepickr
4
+ (Discourse JSON), hackernews (Algolia). See each module's docstring.
5
+ """
6
+
7
+ from .base import SocialPost, SocialQuery, SourceBlocked, SourceSkipped, mentions
8
+
9
+ __all__ = ["SocialPost", "SocialQuery", "SourceBlocked", "SourceSkipped", "mentions"]
@@ -0,0 +1,100 @@
1
+ """Shared shapes for the social-sentiment sources (marketmyna/data/social/*).
2
+
3
+ Every source turns its own payload into SocialPost rows; the service
4
+ (marketmyna/services/social_sentiment.py) scores and aggregates them. A source
5
+ raises SourceBlocked when the site refuses us (rate limit, bot wall) so
6
+ the circuit breaker can back off instead of hammering it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import asdict, dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Optional
15
+
16
+ UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"
17
+ TIMEOUT = 15
18
+
19
+
20
+ class SourceBlocked(Exception):
21
+ """The site refused the request (429, bot wall, network-security page)."""
22
+
23
+
24
+ class SourceSkipped(Exception):
25
+ """The source does not cover this market/symbol, or is switched off."""
26
+
27
+
28
+ @dataclass
29
+ class SocialPost:
30
+ source: str # "reddit" | "stocktwits" | "valuepickr" | "hackernews"
31
+ id: str
32
+ title: str
33
+ url: str
34
+ created_at: datetime # UTC, tz-aware
35
+ body: str = ""
36
+ author: str = ""
37
+ community: str = "" # subreddit, forum category, ...
38
+ score: int = 0 # upvotes / likes / points
39
+ comments: int = 0
40
+ label: Optional[str] = None # author-tagged "bullish" / "bearish" (StockTwits)
41
+
42
+ @property
43
+ def text(self) -> str:
44
+ return f"{self.title}\n{self.body}".strip()
45
+
46
+ def to_dict(self) -> dict:
47
+ data = asdict(self)
48
+ data["created_at"] = self.created_at.isoformat()
49
+ return data
50
+
51
+
52
+ @dataclass
53
+ class SocialQuery:
54
+ """What to look for: the ticker, the company name, and the market."""
55
+
56
+ symbol: str # app symbol, e.g. AAPL, RELIANCE, BTC-USD
57
+ market: str # us | india | crypto | ...
58
+ name: str = "" # cleaned company name, e.g. "Apple", "Reliance Industries"
59
+ aliases: list[str] = field(default_factory=list)
60
+
61
+ @property
62
+ def ticker(self) -> str:
63
+ """Bare ticker as people type it ("BTC" for BTC-USD, "RELIANCE")."""
64
+ return self.symbol.split("-")[0].split(".")[0].upper()
65
+
66
+
67
+ def utc(ts) -> datetime:
68
+ """Epoch seconds or ISO string -> tz-aware UTC datetime."""
69
+ if isinstance(ts, (int, float)):
70
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
71
+ value = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
72
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
73
+
74
+
75
+ # Tickers that are also everyday words/abbreviations: a bare mention is not
76
+ # enough, it needs a $cashtag or the company name.
77
+ _AMBIGUOUS = {
78
+ "A", "ALL", "AN", "ANY", "ARE", "AT", "BE", "CAN", "CAR", "DD", "EOD", "FOR", "GO", "HAS", "IT", "KEY",
79
+ "LOW", "MAN", "NOW", "ON", "ONE", "OPEN", "OR", "OUT", "PLAY", "REAL", "RUN", "SEE", "SO", "TV", "TWO",
80
+ "UP", "USA", "WELL", "YOU", "F", "T", "U", "V", "X", "C", "AI", "CEO", "IPO", "ATH", "YOLO", "EV", "GDP",
81
+ "CPI", "FED", "SEC", "ETF", "EPS", "PE", "IV", "OTM", "ITM", "LIFE", "NEXT", "BIG", "GOOD", "FUN", "LOVE",
82
+ }
83
+
84
+
85
+ def mentions(post: SocialPost, query: SocialQuery) -> bool:
86
+ """True when the post is about the stock. Bare tickers must be upper case
87
+ in the text so "nvda" in a URL slug or "meta" the word don't match."""
88
+ text = post.text
89
+ ticker = query.ticker
90
+ if re.search(rf"\${re.escape(ticker)}\b", text, re.IGNORECASE):
91
+ return True
92
+ if len(ticker) >= 3 and ticker not in _AMBIGUOUS and re.search(rf"\b{re.escape(ticker)}\b", text):
93
+ return True
94
+ for name in [query.name, *query.aliases]:
95
+ name = (name or "").strip()
96
+ if len(name) >= 3 and re.search(
97
+ r"\b" + r"\s+".join(re.escape(w) for w in name.split()) + r"\b", text, re.IGNORECASE
98
+ ):
99
+ return True
100
+ return False
@@ -0,0 +1,92 @@
1
+ """Headless Chromium for sources that refuse plain HTTP clients
2
+ (Reddit's pages, StockTwits behind Cloudflare).
3
+
4
+ One short-lived browser per call, at most two at a time - Chromium costs
5
+ 300-500 MB each, so this stays bounded on a small server. Playwright is an
6
+ optional dependency: without it (or its Chromium download) every browser
7
+ source reports itself unavailable and the other sources still run.
8
+
9
+ pip install playwright && python -m playwright install chromium
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import threading
16
+ from contextlib import contextmanager
17
+ from typing import Iterator
18
+
19
+ from .base import UA, SourceBlocked, SourceSkipped
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _SLOTS = threading.BoundedSemaphore(2)
24
+
25
+ # Text of the pages sites show instead of content when they block a bot.
26
+ _BLOCK_MARKERS = (
27
+ "blocked by network security",
28
+ "just a moment...",
29
+ "too many requests",
30
+ "log in to use old reddit",
31
+ "verify you are human",
32
+ )
33
+
34
+
35
+ def available() -> bool:
36
+ try:
37
+ import playwright.sync_api # noqa: F401
38
+ except ImportError:
39
+ return False
40
+ return True
41
+
42
+
43
+ @contextmanager
44
+ def page(timeout_ms: int = 30_000) -> Iterator["object"]:
45
+ """A fresh Playwright page in its own browser; closed on exit."""
46
+ if not available():
47
+ raise SourceSkipped("Playwright is not installed (pip install playwright)")
48
+ from playwright.sync_api import sync_playwright
49
+
50
+ with _SLOTS, sync_playwright() as pw:
51
+ browser = pw.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
52
+ try:
53
+ context = browser.new_context(user_agent=UA, locale="en-US", viewport={"width": 1280, "height": 900})
54
+ pg = context.new_page()
55
+ pg.set_default_timeout(timeout_ms)
56
+ # Images/fonts/media are never needed - skip them to save time and bandwidth.
57
+ pg.route(
58
+ "**/*",
59
+ lambda route: route.abort()
60
+ if route.request.resource_type in ("image", "media", "font")
61
+ else route.continue_(),
62
+ )
63
+ yield pg
64
+ finally:
65
+ browser.close()
66
+
67
+
68
+ def check_blocked(pg, status: int | None = None) -> None:
69
+ """Raise SourceBlocked if the loaded page is a bot wall / rate limit."""
70
+ if status == 429:
71
+ raise SourceBlocked("rate limited (429)")
72
+ try:
73
+ head = (pg.title() + " " + pg.inner_text("body")[:600]).lower()
74
+ except Exception:
75
+ return
76
+ for marker in _BLOCK_MARKERS:
77
+ if marker in head:
78
+ raise SourceBlocked(marker)
79
+
80
+
81
+ def get_text(url: str, timeout_ms: int = 30_000) -> str:
82
+ """Load a URL in the browser and return the body text (for JSON endpoints
83
+ that only answer real browsers)."""
84
+ with page(timeout_ms) as pg:
85
+ response = pg.goto(url, wait_until="domcontentloaded")
86
+ status = response.status if response else None
87
+ body = pg.inner_text("body")
88
+ if status == 429 or (not body.lstrip().startswith(("{", "[")) and status != 200):
89
+ raise SourceBlocked(f"HTTP {status}")
90
+ if not body.lstrip().startswith(("{", "[")):
91
+ check_blocked(pg, status)
92
+ return body
@@ -0,0 +1,53 @@
1
+ """Hacker News stories via the free Algolia search API (no key).
2
+
3
+ Useful mostly for US tech names; story titles only (comments are too
4
+ off-topic to score reliably).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+ import requests
12
+
13
+ from .base import TIMEOUT, SocialPost, SocialQuery, SourceBlocked, SourceSkipped, utc
14
+
15
+ API = "https://hn.algolia.com/api/v1/search_by_date"
16
+
17
+
18
+ def parse_hits(payload: dict) -> list[SocialPost]:
19
+ posts = []
20
+ for h in payload.get("hits") or []:
21
+ if not h.get("objectID") or not h.get("title"):
22
+ continue
23
+ posts.append(
24
+ SocialPost(
25
+ source="hackernews",
26
+ id=f"hackernews:{h['objectID']}",
27
+ title=h["title"],
28
+ body=(h.get("story_text") or "")[:2000],
29
+ url=f"https://news.ycombinator.com/item?id={h['objectID']}",
30
+ created_at=utc(int(h.get("created_at_i") or 0)),
31
+ author=h.get("author") or "",
32
+ community="Hacker News",
33
+ score=int(h.get("points") or 0),
34
+ comments=int(h.get("num_comments") or 0),
35
+ )
36
+ )
37
+ return posts
38
+
39
+
40
+ def fetch(query: SocialQuery, config=None, days: int = 14) -> list[SocialPost]:
41
+ if query.market not in ("us", "crypto"):
42
+ raise SourceSkipped("Hacker News is used for US stocks and crypto")
43
+ params = {
44
+ "query": query.name or query.ticker,
45
+ "tags": "story",
46
+ "numericFilters": f"created_at_i>{int(time.time()) - days * 86400}",
47
+ "hitsPerPage": 50,
48
+ }
49
+ r = requests.get(API, params=params, timeout=TIMEOUT)
50
+ if r.status_code == 429:
51
+ raise SourceBlocked("Hacker News rate limit")
52
+ r.raise_for_status()
53
+ return parse_hits(r.json())