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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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())
|