marketmyna 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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 "")