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,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())
|