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,511 @@
|
|
|
1
|
+
"""What people are saying about a stock on Reddit, StockTwits, ValuePickr
|
|
2
|
+
and Hacker News - scored, aggregated, and summarised for the Analyze page.
|
|
3
|
+
|
|
4
|
+
Pipeline: build_query -> each source (in parallel, each with its own
|
|
5
|
+
deadline and failure isolation) -> dedupe + mention filter + time window ->
|
|
6
|
+
score each post -> aggregate (engagement- and recency-weighted) -> themes,
|
|
7
|
+
daily timeline, buzz -> snapshot to the db for the trend line.
|
|
8
|
+
|
|
9
|
+
Scoring is a finance + retail-trader lexicon (moon, bagholder, puts, ...)
|
|
10
|
+
with negation; StockTwits' author-set Bullish/Bearish tag outweighs it.
|
|
11
|
+
No AI is needed for the numbers; ai_summary() adds an optional LLM read.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
from datetime import date, datetime, timedelta, timezone
|
|
23
|
+
from typing import Callable, Optional
|
|
24
|
+
|
|
25
|
+
from ..data.social import SocialPost, SocialQuery, SourceBlocked, SourceSkipped, mentions
|
|
26
|
+
from ..llm.json_reply import parse_object
|
|
27
|
+
from ..utils.deadline import call_with_deadline
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
CACHE_SECONDS = 20 * 60
|
|
32
|
+
WINDOW_DAYS = 7
|
|
33
|
+
SLOW_WINDOW_DAYS = 30 # forums (ValuePickr) move slower
|
|
34
|
+
HALF_LIFE_HOURS = 72
|
|
35
|
+
SOURCE_DEADLINE = 75
|
|
36
|
+
|
|
37
|
+
# Sources that return "the latest N" rather than everything in the window.
|
|
38
|
+
SNAPSHOT_SOURCES = {"stocktwits"}
|
|
39
|
+
|
|
40
|
+
SOURCE_LABELS = {"reddit": "Reddit", "stocktwits": "StockTwits", "valuepickr": "ValuePickr", "hackernews": "Hacker News"}
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------ scoring
|
|
43
|
+
|
|
44
|
+
# Phrases first (multi-word), then words. Weights: 1 normal, 1.5 strong.
|
|
45
|
+
_BULL = {
|
|
46
|
+
"to the moon": 1.5, "short squeeze": 1, "buy the dip": 1, "btfd": 1, "diamond hands": 1, "strong buy": 1.5,
|
|
47
|
+
"all time high": 1, "loading up": 1, "load up": 1, "buying calls": 1.5, "bought calls": 1.5, "price target raised": 1.5,
|
|
48
|
+
"beat estimates": 1.5, "beats estimates": 1.5, "going up": 1, "long term hold": 1, "holding strong": 1,
|
|
49
|
+
"moon": 1, "mooning": 1.5, "rocket": 1, "calls": 0.5, "bullish": 1.5, "bull": 1, "undervalued": 1.5,
|
|
50
|
+
"breakout": 1, "tendies": 1, "printing": 1, "rally": 1, "rallying": 1, "ripping": 1, "squeeze": 0.5,
|
|
51
|
+
"upgrade": 1.5, "upgraded": 1.5, "outperform": 1, "beat": 1, "beats": 1, "crushed": 0.5, "surge": 1,
|
|
52
|
+
"surging": 1, "soar": 1, "soaring": 1, "jump": 0.5, "gain": 0.5, "gains": 0.5, "green": 0.5, "buy": 0.5,
|
|
53
|
+
"buying": 0.5, "bought": 0.5, "accumulate": 1, "accumulating": 1, "adding": 0.5, "multibagger": 1.5,
|
|
54
|
+
"rerating": 1, "re-rating": 1, "strong": 0.5, "growth": 0.5, "record": 0.5, "profit": 0.5, "profitable": 0.5,
|
|
55
|
+
"upside": 1, "conviction": 0.5, "winner": 1, "winning": 0.5, "love": 0.5, "great": 0.5, "excellent": 1,
|
|
56
|
+
"recovery": 0.5, "turnaround": 1, "momentum": 0.5, "ath": 1, "lfg": 1,
|
|
57
|
+
}
|
|
58
|
+
_BEAR = {
|
|
59
|
+
"rug pull": 1.5, "value trap": 1.5, "falling knife": 1.5, "stay away": 1.5, "bag holder": 1.5, "guidance cut": 1.5,
|
|
60
|
+
"buying puts": 1.5, "bought puts": 1.5, "price target cut": 1.5, "going down": 1, "dead cat": 1.5,
|
|
61
|
+
"missed estimates": 1.5, "sell off": 1, "selloff": 1,
|
|
62
|
+
"puts": 0.5, "short": 0.5, "shorting": 1, "shorts": 0.5, "bearish": 1.5, "bear": 1, "overvalued": 1.5,
|
|
63
|
+
"bubble": 1, "dump": 1, "dumping": 1, "dumped": 1, "tank": 1, "tanking": 1.5, "tanked": 1, "crash": 1,
|
|
64
|
+
"crashing": 1.5, "bagholder": 1.5, "bagholders": 1.5, "bagholding": 1.5, "rug": 1, "scam": 1.5, "fraud": 1.5,
|
|
65
|
+
"sell": 0.5, "selling": 0.5, "sold": 0.5, "exit": 0.5, "exited": 0.5, "red": 0.5, "bleeding": 1, "drilling": 1,
|
|
66
|
+
"miss": 1, "missed": 1, "downgrade": 1.5, "downgraded": 1.5, "underperform": 1, "lawsuit": 1, "dilution": 1,
|
|
67
|
+
"dilutive": 1, "dead": 1, "worthless": 1.5, "avoid": 1, "trap": 1, "overhyped": 1, "hype": 0.5, "lost": 0.5,
|
|
68
|
+
"loss": 0.5, "losses": 0.5, "losing": 0.5, "decline": 0.5, "declining": 0.5, "drop": 0.5, "dropping": 0.5,
|
|
69
|
+
"plunge": 1, "plunged": 1, "weak": 0.5, "concern": 0.5, "concerns": 0.5, "risky": 0.5, "debt": 0.5,
|
|
70
|
+
"terrible": 1, "awful": 1, "disappointing": 1, "disappointed": 1, "regret": 1, "down bad": 1, "rekt": 1.5,
|
|
71
|
+
}
|
|
72
|
+
_EMOJI = {"🚀": ("bull", 1), "💎": ("bull", 0.5), "📈": ("bull", 1), "🐂": ("bull", 1), "🌙": ("bull", 0.5),
|
|
73
|
+
"📉": ("bear", 1), "🐻": ("bear", 1), "🩸": ("bear", 0.5), "💀": ("bear", 0.5), "🤡": ("bear", 0.5)}
|
|
74
|
+
_NEGATORS = {"not", "no", "never", "dont", "don't", "isnt", "isn't", "wont", "won't", "cant", "can't", "aint",
|
|
75
|
+
"ain't", "without", "hardly", "nothing", "neither", "nor", "stop", "didnt", "didn't"}
|
|
76
|
+
_TERMS = sorted(
|
|
77
|
+
[(t, "bull", w) for t, w in _BULL.items()] + [(t, "bear", w) for t, w in _BEAR.items()],
|
|
78
|
+
key=lambda x: -len(x[0]),
|
|
79
|
+
)
|
|
80
|
+
_TERM_RE = re.compile(r"\b(" + "|".join(re.escape(t) for t, _, _ in _TERMS) + r")\b")
|
|
81
|
+
_TERM_INFO = {t: (side, w) for t, side, w in _TERMS}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def score_text(text: str) -> tuple[float, list[str]]:
|
|
85
|
+
"""-1..1 for one post, plus the terms that drove it.
|
|
86
|
+
|
|
87
|
+
A term preceded (within 3 words) by a negator flips side: "not bullish"
|
|
88
|
+
reads bearish, "don't sell" reads bullish."""
|
|
89
|
+
low = re.sub(r"https?://\S+", " ", text.lower()).replace("’", "'")
|
|
90
|
+
bull = bear = 0.0
|
|
91
|
+
hits: list[str] = []
|
|
92
|
+
for match in _TERM_RE.finditer(low):
|
|
93
|
+
term = match.group(1)
|
|
94
|
+
side, weight = _TERM_INFO[term]
|
|
95
|
+
before = re.findall(r"[a-z']+", low[max(0, match.start() - 40):match.start()])[-3:]
|
|
96
|
+
if any(w in _NEGATORS for w in before):
|
|
97
|
+
side = "bear" if side == "bull" else "bull"
|
|
98
|
+
term = f"not {term}"
|
|
99
|
+
if side == "bull":
|
|
100
|
+
bull += weight
|
|
101
|
+
else:
|
|
102
|
+
bear += weight
|
|
103
|
+
hits.append(term)
|
|
104
|
+
for emoji, (side, weight) in _EMOJI.items():
|
|
105
|
+
n = min(text.count(emoji), 3)
|
|
106
|
+
if n:
|
|
107
|
+
bull, bear = (bull + weight * n, bear) if side == "bull" else (bull, bear + weight * n)
|
|
108
|
+
hits.append(emoji)
|
|
109
|
+
total = bull + bear
|
|
110
|
+
if total == 0:
|
|
111
|
+
return 0.0, hits
|
|
112
|
+
# Direction from the balance, strength from how much evidence there is:
|
|
113
|
+
# a single weak word gives a mild reading, several give a firm one.
|
|
114
|
+
return round((bull - bear) / total * min(1.0, total / 2.0), 3), hits
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def post_sentiment(post: SocialPost) -> float:
|
|
118
|
+
lex, _ = score_text(post.text)
|
|
119
|
+
if post.label == "bullish":
|
|
120
|
+
return round(0.7 + 0.3 * lex, 3)
|
|
121
|
+
if post.label == "bearish":
|
|
122
|
+
return round(-0.7 + 0.3 * lex, 3)
|
|
123
|
+
return lex
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def classify(score: float) -> str:
|
|
127
|
+
return "bullish" if score > 0.15 else "bearish" if score < -0.15 else "neutral"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mood(score100: float) -> str:
|
|
131
|
+
if score100 >= 35:
|
|
132
|
+
return "very bullish"
|
|
133
|
+
if score100 >= 10:
|
|
134
|
+
return "bullish"
|
|
135
|
+
if score100 <= -35:
|
|
136
|
+
return "very bearish"
|
|
137
|
+
if score100 <= -10:
|
|
138
|
+
return "bearish"
|
|
139
|
+
return "neutral"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def weight(post: SocialPost, now: datetime) -> float:
|
|
143
|
+
engagement = 1 + math.log1p(max(post.score, 0)) + 0.5 * math.log1p(max(post.comments, 0))
|
|
144
|
+
age_h = max(0.0, (now - post.created_at).total_seconds() / 3600)
|
|
145
|
+
return engagement * 0.5 ** (age_h / HALF_LIFE_HOURS)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------- themes
|
|
149
|
+
|
|
150
|
+
THEMES: dict[str, re.Pattern] = {
|
|
151
|
+
name: re.compile(rx, re.I)
|
|
152
|
+
for name, rx in {
|
|
153
|
+
"Earnings": r"\bearnings\b|\beps\b|\bq[1-4]\b|\bresults\b|\bquarter(ly)?\b",
|
|
154
|
+
"Guidance": r"\bguidance\b|\boutlook\b|\bforecast\b",
|
|
155
|
+
"Options activity": r"\bcalls?\b|\bputs?\b|\boptions?\b|\b0dte\b|\bstrike\b|\byolo\b",
|
|
156
|
+
"Short squeeze": r"\bsqueeze\b|\bshort interest\b|\bshorts?\b",
|
|
157
|
+
"Valuation": r"\bvaluation\b|\bovervalued\b|\bundervalued\b|\bp/?e\b|\bmultiple\b|\bcheap\b|\bexpensive\b",
|
|
158
|
+
"Dividends": r"\bdividends?\b|\byield\b|\bpayout\b",
|
|
159
|
+
"Buyback": r"\bbuybacks?\b|\brepurchase",
|
|
160
|
+
"M&A": r"\bmerger\b|\bacquisition\b|\bacquir|\btakeover\b|\bbuyout\b",
|
|
161
|
+
"Analyst ratings": r"\bupgrade|\bdowngrade|\bprice target\b|\banalysts?\b",
|
|
162
|
+
"AI": r"\bai\b|\bartificial intelligence\b|\bgpus?\b|\bllms?\b|\bdata ?cent(er|re)s?\b",
|
|
163
|
+
"Legal / regulation": r"\blawsuit\b|\bsec\b|\bsebi\b|\bprobe\b|\binvestigation\b|\bregulat|\bantitrust\b|\bfined?\b",
|
|
164
|
+
"Management": r"\bceo\b|\bcfo\b|\bmanagement\b|\binsiders?\b|\bpromoters?\b",
|
|
165
|
+
"Layoffs": r"\blayoffs?\b|\bjob cuts\b|\brestructur",
|
|
166
|
+
"Debt": r"\bdebt\b|\bloans?\b|\bbonds?\b|\bdefault\b|\brefinanc",
|
|
167
|
+
"Technical analysis": r"\bsupport\b|\bresistance\b|\bbreakout\b|\bchart\b|\brsi\b|\bmoving average\b|\b\d+ ?dma\b",
|
|
168
|
+
"Products / orders": r"\blaunch(es|ed)?\b|\bproducts?\b|\border book\b|\bcontracts?\b|\bdeal\b",
|
|
169
|
+
"Macro / rates": r"\bfed\b|\brbi\b|\brate (cut|hike)s?\b|\binflation\b|\btariffs?\b|\brecession\b",
|
|
170
|
+
}.items()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def themes(scored: list[tuple[SocialPost, float]], limit: int = 8) -> list[dict]:
|
|
175
|
+
counts: dict[str, list[float]] = {}
|
|
176
|
+
for post, s in scored:
|
|
177
|
+
for name, rx in THEMES.items():
|
|
178
|
+
if rx.search(post.text):
|
|
179
|
+
counts.setdefault(name, []).append(s)
|
|
180
|
+
min_posts = 2 if len(scored) >= 10 else 1
|
|
181
|
+
out = [
|
|
182
|
+
{"theme": name, "posts": len(vals), "score": round(sum(vals) / len(vals) * 100), "tone": classify(sum(vals) / len(vals))}
|
|
183
|
+
for name, vals in counts.items()
|
|
184
|
+
if len(vals) >= min_posts
|
|
185
|
+
]
|
|
186
|
+
return sorted(out, key=lambda t: -t["posts"])[:limit]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------- query
|
|
190
|
+
|
|
191
|
+
_GENERIC_FIRST = {
|
|
192
|
+
"american", "general", "united", "first", "national", "global", "international", "bank", "india", "indian",
|
|
193
|
+
"the", "new", "advanced", "applied", "western", "eastern", "southern", "northern", "royal", "capital", "energy",
|
|
194
|
+
"tata", "adani", "bajaj", "mahindra", "hdfc", "icici", "state", "great", "china", "hong", "power", "digital",
|
|
195
|
+
}
|
|
196
|
+
_CRYPTO_NAMES = {
|
|
197
|
+
"BTC": "Bitcoin", "ETH": "Ethereum", "SOL": "Solana", "XRP": "Ripple", "DOGE": "Dogecoin", "ADA": "Cardano",
|
|
198
|
+
"BNB": "Binance Coin", "AVAX": "Avalanche", "DOT": "Polkadot", "LINK": "Chainlink", "MATIC": "Polygon",
|
|
199
|
+
"LTC": "Litecoin", "SHIB": "Shiba Inu", "TRX": "Tron", "TON": "Toncoin",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def clean_name(name: str) -> str:
|
|
204
|
+
from ..data.news_search import _SUFFIXES
|
|
205
|
+
|
|
206
|
+
name = _SUFFIXES.sub("", name or "")
|
|
207
|
+
return " ".join(re.sub(r"[.,()&]+", " ", name).split())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_query(symbol: str, market: str, name: Optional[str] = None) -> SocialQuery:
|
|
211
|
+
symbol, market = symbol.strip().upper(), market.strip().lower()
|
|
212
|
+
query = SocialQuery(symbol=symbol, market=market)
|
|
213
|
+
if market == "crypto":
|
|
214
|
+
query.name = _CRYPTO_NAMES.get(query.ticker, "")
|
|
215
|
+
return query
|
|
216
|
+
if name is None:
|
|
217
|
+
from ..data.news_search import company_name
|
|
218
|
+
|
|
219
|
+
name = company_name(symbol, market)
|
|
220
|
+
cleaned = clean_name(name)
|
|
221
|
+
query.name = "" if cleaned.upper() == symbol else cleaned
|
|
222
|
+
words = query.name.split()
|
|
223
|
+
# "Nvidia" for "NVIDIA", "Microsoft" for "Microsoft" - a distinctive first
|
|
224
|
+
# word is how people actually write the name.
|
|
225
|
+
if market == "us" and len(words) >= 2 and len(words[0]) >= 4 and words[0].lower() not in _GENERIC_FIRST:
|
|
226
|
+
query.aliases.append(words[0])
|
|
227
|
+
return query
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------- sources
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sources() -> dict[str, Callable]:
|
|
234
|
+
from ..data.social import hackernews, reddit, stocktwits, valuepickr
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"reddit": reddit.fetch,
|
|
238
|
+
"stocktwits": stocktwits.fetch,
|
|
239
|
+
"valuepickr": valuepickr.fetch,
|
|
240
|
+
"hackernews": hackernews.fetch,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_source(name: str, fetcher: Callable, query: SocialQuery, config) -> tuple[list[SocialPost], dict]:
|
|
245
|
+
status = {"source": name, "label": SOURCE_LABELS.get(name, name), "status": "ok", "posts": 0, "via": None, "message": None}
|
|
246
|
+
try:
|
|
247
|
+
result = call_with_deadline(fetcher, SOURCE_DEADLINE, query, config)
|
|
248
|
+
except SourceSkipped as exc:
|
|
249
|
+
status.update(status="skipped", message=str(exc))
|
|
250
|
+
return [], status
|
|
251
|
+
except SourceBlocked as exc:
|
|
252
|
+
status.update(status="blocked", message=str(exc))
|
|
253
|
+
return [], status
|
|
254
|
+
except TimeoutError:
|
|
255
|
+
status.update(status="error", message="timed out")
|
|
256
|
+
return [], status
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.warning("Social source %s failed: %s", name, exc)
|
|
259
|
+
status.update(status="error", message=str(exc)[:200])
|
|
260
|
+
return [], status
|
|
261
|
+
posts, via = result if isinstance(result, tuple) else (result, None)
|
|
262
|
+
status["via"] = via
|
|
263
|
+
return posts, status
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def collect(query: SocialQuery, config=None, sources: Optional[dict[str, Callable]] = None) -> tuple[list[SocialPost], list[dict]]:
|
|
267
|
+
sources = sources if sources is not None else _sources()
|
|
268
|
+
with ThreadPoolExecutor(max_workers=len(sources) or 1) as pool:
|
|
269
|
+
futures = {name: pool.submit(_run_source, name, fn, query, config) for name, fn in sources.items()}
|
|
270
|
+
results = {name: f.result() for name, f in futures.items()}
|
|
271
|
+
posts: list[SocialPost] = []
|
|
272
|
+
statuses: list[dict] = []
|
|
273
|
+
for name in sources:
|
|
274
|
+
found, status = results[name]
|
|
275
|
+
posts.extend(found)
|
|
276
|
+
statuses.append(status)
|
|
277
|
+
return posts, statuses
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------- aggregate
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _window(post: SocialPost) -> int:
|
|
284
|
+
return SLOW_WINDOW_DAYS if post.source == "valuepickr" else WINDOW_DAYS
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def select_posts(posts: list[SocialPost], query: SocialQuery, now: datetime) -> list[SocialPost]:
|
|
288
|
+
"""Dedupe, keep posts about this stock, inside the time window."""
|
|
289
|
+
seen: set[str] = set()
|
|
290
|
+
out = []
|
|
291
|
+
for p in sorted(posts, key=lambda p: p.created_at, reverse=True):
|
|
292
|
+
if p.id in seen or p.created_at < now - timedelta(days=_window(p)) or p.created_at > now + timedelta(hours=1):
|
|
293
|
+
continue
|
|
294
|
+
# StockTwits streams are per-symbol already; everything else must mention the stock.
|
|
295
|
+
if p.source != "stocktwits" and not mentions(p, query):
|
|
296
|
+
continue
|
|
297
|
+
seen.add(p.id)
|
|
298
|
+
out.append(p)
|
|
299
|
+
return out
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def aggregate(posts: list[SocialPost], now: Optional[datetime] = None) -> dict:
|
|
303
|
+
now = now or datetime.now(timezone.utc)
|
|
304
|
+
scored = [(p, post_sentiment(p)) for p in posts]
|
|
305
|
+
weights = [weight(p, now) for p, _ in scored]
|
|
306
|
+
total_w = sum(weights)
|
|
307
|
+
overall = sum(w * s for w, (_, s) in zip(weights, scored)) / total_w if total_w else 0.0
|
|
308
|
+
score100 = round(overall * 100)
|
|
309
|
+
tones = [classify(s) for _, s in scored]
|
|
310
|
+
counts = {t: tones.count(t) for t in ("bullish", "neutral", "bearish")}
|
|
311
|
+
|
|
312
|
+
by_source: dict[str, list[float]] = {}
|
|
313
|
+
for p, s in scored:
|
|
314
|
+
by_source.setdefault(p.source, []).append(s)
|
|
315
|
+
|
|
316
|
+
# Volume (timeline counts, buzz) only from sources that return the whole
|
|
317
|
+
# window; StockTwits always hands back its latest 30 messages, which
|
|
318
|
+
# would read as a fake spike today.
|
|
319
|
+
volume = [(p, s) for p, s in scored if p.source not in SNAPSHOT_SOURCES]
|
|
320
|
+
|
|
321
|
+
# Daily buckets over the short window (unweighted mean per day).
|
|
322
|
+
days = [(now - timedelta(days=i)).date() for i in range(WINDOW_DAYS - 1, -1, -1)]
|
|
323
|
+
buckets: dict[date, list[float]] = {d: [] for d in days}
|
|
324
|
+
for p, s in volume:
|
|
325
|
+
d = p.created_at.date()
|
|
326
|
+
if d in buckets:
|
|
327
|
+
buckets[d].append(s)
|
|
328
|
+
timeline = [
|
|
329
|
+
{"day": d.isoformat(), "posts": len(v), "score": round(sum(v) / len(v) * 100) if v else None}
|
|
330
|
+
for d, v in buckets.items()
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
last_24h = sum(1 for p, _ in volume if p.created_at >= now - timedelta(hours=24))
|
|
334
|
+
prior = [p for p, _ in volume if now - timedelta(days=WINDOW_DAYS) <= p.created_at < now - timedelta(hours=24)]
|
|
335
|
+
prior_daily = len(prior) / (WINDOW_DAYS - 1)
|
|
336
|
+
buzz = None
|
|
337
|
+
if prior_daily >= 0.5:
|
|
338
|
+
buzz = {"last_24h": last_24h, "daily_avg": round(prior_daily, 1), "ratio": round(last_24h / prior_daily, 2)}
|
|
339
|
+
|
|
340
|
+
top = sorted(zip(scored, weights), key=lambda x: -x[1])[:15]
|
|
341
|
+
return {
|
|
342
|
+
"score": score100,
|
|
343
|
+
"mood": mood(score100) if posts else None,
|
|
344
|
+
"confidence": "high" if len(posts) >= 40 else "medium" if len(posts) >= 12 else "low",
|
|
345
|
+
"posts": len(posts),
|
|
346
|
+
"counts": counts,
|
|
347
|
+
"by_source": [
|
|
348
|
+
{"source": src, "label": SOURCE_LABELS.get(src, src), "posts": len(v), "score": round(sum(v) / len(v) * 100)}
|
|
349
|
+
for src, v in sorted(by_source.items(), key=lambda kv: -len(kv[1]))
|
|
350
|
+
],
|
|
351
|
+
"themes": themes(scored),
|
|
352
|
+
"timeline": timeline,
|
|
353
|
+
"buzz": buzz,
|
|
354
|
+
"top_posts": [
|
|
355
|
+
{
|
|
356
|
+
**{k: v for k, v in p.to_dict().items() if k != "body"},
|
|
357
|
+
"excerpt": (p.body[:240] + "…") if len(p.body) > 240 else p.body,
|
|
358
|
+
"sentiment": round(s * 100),
|
|
359
|
+
"tone": classify(s),
|
|
360
|
+
"terms": score_text(p.text)[1][:6],
|
|
361
|
+
}
|
|
362
|
+
for (p, s), _w in top
|
|
363
|
+
],
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --------------------------------------------------------- cache + history
|
|
368
|
+
|
|
369
|
+
_cache: dict[tuple[str, str], tuple[float, dict]] = {}
|
|
370
|
+
_cache_lock = threading.Lock()
|
|
371
|
+
_inflight: dict[tuple[str, str], threading.Lock] = {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _save_snapshot(db, data: dict) -> None:
|
|
375
|
+
from ..storage import SocialSentimentRecord as R
|
|
376
|
+
|
|
377
|
+
day = date.today().isoformat()
|
|
378
|
+
with db.get_session() as session:
|
|
379
|
+
row = session.query(R).filter(R.symbol == data["symbol"], R.market == data["market"], R.day == day).first()
|
|
380
|
+
if row is None:
|
|
381
|
+
row = R(symbol=data["symbol"], market=data["market"], day=day)
|
|
382
|
+
session.add(row)
|
|
383
|
+
row.score = data["score"]
|
|
384
|
+
row.posts = data["posts"]
|
|
385
|
+
row.bullish, row.bearish, row.neutral = (data["counts"][k] for k in ("bullish", "bearish", "neutral"))
|
|
386
|
+
row.updated_at = datetime.now()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def history(db, symbol: str, market: str, days: int = 30) -> list[dict]:
|
|
390
|
+
from ..storage import SocialSentimentRecord as R
|
|
391
|
+
|
|
392
|
+
since = (date.today() - timedelta(days=days)).isoformat()
|
|
393
|
+
with db.get_session() as session:
|
|
394
|
+
rows = (
|
|
395
|
+
session.query(R)
|
|
396
|
+
.filter(R.symbol == symbol, R.market == market, R.day >= since)
|
|
397
|
+
.order_by(R.day)
|
|
398
|
+
.all()
|
|
399
|
+
)
|
|
400
|
+
return [{"day": r.day, "score": round(r.score), "posts": r.posts} for r in rows]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def build(symbol: str, market: str, config=None, db=None, refresh: bool = False,
|
|
404
|
+
sources: Optional[dict[str, Callable]] = None) -> dict:
|
|
405
|
+
"""Social sentiment for one stock (cached CACHE_SECONDS)."""
|
|
406
|
+
key = (symbol.strip().upper(), market.strip().lower())
|
|
407
|
+
with _cache_lock:
|
|
408
|
+
hit = _cache.get(key)
|
|
409
|
+
if hit and not refresh and time.time() - hit[0] < CACHE_SECONDS:
|
|
410
|
+
return hit[1]
|
|
411
|
+
lock = _inflight.setdefault(key, threading.Lock())
|
|
412
|
+
# One fetch per stock at a time: a second viewer waits for the first
|
|
413
|
+
# instead of launching another browser at the same site.
|
|
414
|
+
with lock:
|
|
415
|
+
with _cache_lock:
|
|
416
|
+
hit = _cache.get(key)
|
|
417
|
+
if hit and time.time() - hit[0] < (5 if refresh else CACHE_SECONDS):
|
|
418
|
+
return hit[1]
|
|
419
|
+
now = datetime.now(timezone.utc)
|
|
420
|
+
query = build_query(*key)
|
|
421
|
+
raw, statuses = collect(query, config, sources)
|
|
422
|
+
posts = select_posts(raw, query, now)
|
|
423
|
+
for status in statuses:
|
|
424
|
+
status["posts"] = sum(1 for p in posts if p.source == status["source"])
|
|
425
|
+
if status["status"] == "ok" and not status["posts"]:
|
|
426
|
+
status["status"] = "empty"
|
|
427
|
+
data = {
|
|
428
|
+
"symbol": key[0],
|
|
429
|
+
"market": key[1],
|
|
430
|
+
"query": {"ticker": query.ticker, "name": query.name, "aliases": query.aliases},
|
|
431
|
+
"fetched_at": now.isoformat(),
|
|
432
|
+
"window_days": WINDOW_DAYS,
|
|
433
|
+
"sources": statuses,
|
|
434
|
+
**aggregate(posts, now),
|
|
435
|
+
"history": [],
|
|
436
|
+
}
|
|
437
|
+
if db is not None:
|
|
438
|
+
try:
|
|
439
|
+
if posts:
|
|
440
|
+
_save_snapshot(db, data)
|
|
441
|
+
data["history"] = history(db, key[0], key[1])
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
logger.warning("Social sentiment history unavailable: %s", exc)
|
|
444
|
+
with _cache_lock:
|
|
445
|
+
_cache[key] = (time.time(), data)
|
|
446
|
+
return data
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def clear_cache() -> None:
|
|
450
|
+
with _cache_lock:
|
|
451
|
+
_cache.clear()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ------------------------------------------------------------- AI summary
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _parse_summary(text: str) -> Optional[dict]:
|
|
458
|
+
data = parse_object(text)
|
|
459
|
+
if data is None:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
def strings(value) -> list[str]:
|
|
463
|
+
return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, list) else []
|
|
464
|
+
|
|
465
|
+
hype = str(data.get("hype_risk") or "").lower()
|
|
466
|
+
out = {
|
|
467
|
+
"summary": str(data.get("summary") or "").strip(),
|
|
468
|
+
"bull_points": strings(data.get("bull_points"))[:5],
|
|
469
|
+
"bear_points": strings(data.get("bear_points"))[:5],
|
|
470
|
+
"hype_risk": hype if hype in ("low", "medium", "high") else "low",
|
|
471
|
+
"hype_reason": str(data.get("hype_reason") or "").strip(),
|
|
472
|
+
}
|
|
473
|
+
return out if out["summary"] else None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def ai_summary(data: dict, config) -> Optional[dict]:
|
|
477
|
+
"""LLM digest of the top posts. None without an LLM or on a bad reply."""
|
|
478
|
+
from ..llm.channels import llm_configured
|
|
479
|
+
|
|
480
|
+
if config is None or not llm_configured(config) or not data.get("top_posts"):
|
|
481
|
+
return None
|
|
482
|
+
from ..llm.backend_factory import create_generation_backend
|
|
483
|
+
from ..llm.protocol import GenerationConfig
|
|
484
|
+
|
|
485
|
+
lines = [
|
|
486
|
+
f"Stock: {data['query'].get('name') or data['symbol']} ({data['symbol']}, market {data['market']}).",
|
|
487
|
+
f"Lexicon sentiment score: {data['score']} on -100..100 from {data['posts']} posts in the last "
|
|
488
|
+
f"{data['window_days']} days ({data['counts']['bullish']} bullish, {data['counts']['bearish']} bearish).",
|
|
489
|
+
"Most-engaged posts (source | community | upvotes | text):",
|
|
490
|
+
]
|
|
491
|
+
for p in data["top_posts"][:25]:
|
|
492
|
+
text = (p["title"] + " " + (p.get("excerpt") or "")).replace("\n", " ")[:400]
|
|
493
|
+
lines.append(f"- {p['source']} | {p['community']} | {p['score']} | {text}")
|
|
494
|
+
system = (
|
|
495
|
+
"You summarise retail-investor chatter about one stock for other investors. Use ONLY the posts given. "
|
|
496
|
+
"Reply with ONLY a JSON object, no prose around it:\n"
|
|
497
|
+
'{"summary": "2-3 sentences: what people are discussing and the overall mood", '
|
|
498
|
+
'"bull_points": ["2-4 short arguments bulls are making"], '
|
|
499
|
+
'"bear_points": ["2-4 short arguments bears are making"], '
|
|
500
|
+
'"hype_risk": "low | medium | high", '
|
|
501
|
+
'"hype_reason": "1 sentence: signs of pump/meme hype or coordinated promotion, or why it looks organic"}\n'
|
|
502
|
+
"Attribute claims to posters (\"posters argue...\"), never state them as fact. No buy/sell advice."
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
result = create_generation_backend(config).generate(
|
|
506
|
+
"\n".join(lines), GenerationConfig(max_tokens=700, timeout=110), system_prompt=system
|
|
507
|
+
)
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
logger.warning("Social AI summary unavailable: %s", exc)
|
|
510
|
+
return None
|
|
511
|
+
return _parse_summary(result.text or "")
|