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,195 @@
1
+ """AI review of a whole mutual fund portfolio (Portfolio page).
2
+
3
+ Builds a fact sheet from what the page already computes - the fund table
4
+ (marketmyna/services/mf_lumpsum.portfolio_funds) and the health check
5
+ (marketmyna/services/mf_insights.insights) - plus a look-through of the stocks and
6
+ sectors held across all funds, then asks the model to explain it. Wording
7
+ follows marketmyna/core/compliance.py: the owner gets a view and suggested actions,
8
+ public users an explanation without a recommendation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor, wait
19
+ from typing import Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ CACHE_SECONDS = 6 * 3600
24
+ # Reasoning models (gpt-oss, Gemini thinking) spend part of max_tokens before
25
+ # answering; too tight a budget cuts the JSON off mid-way.
26
+ MAX_TOKENS = 4500
27
+ LOOK_THROUGH_SECONDS = 60 # holdings files are cached after the first read; don't hang on a slow one
28
+ MIN_COVERAGE = 50 # % of the portfolio whose holdings must be known to show the look-through
29
+ _cache: dict[tuple, tuple[float, dict]] = {}
30
+ _lock = threading.Lock()
31
+
32
+
33
+
34
+ def _r(x, nd=1):
35
+ return round(x, nd) if isinstance(x, (int, float)) else None
36
+
37
+
38
+ def _short(name: str) -> str:
39
+ return (name or "").split(" - ")[0]
40
+
41
+
42
+ def look_through(weights: dict[str, float]) -> dict:
43
+ """Stocks and sectors across all funds, each fund's holdings scaled by its
44
+ share of the portfolio. Funds whose holdings can't be read are skipped and
45
+ reported in `covered_pct`."""
46
+ from . import fund_holdings
47
+
48
+ pool = ThreadPoolExecutor(max_workers=8)
49
+ futures = {pool.submit(fund_holdings.for_fund, code): code for code in weights}
50
+ done, _ = wait(futures, timeout=LOOK_THROUGH_SECONDS)
51
+ pool.shutdown(wait=False, cancel_futures=True)
52
+
53
+ stocks: dict[str, float] = {}
54
+ sectors: dict[str, float] = {}
55
+ covered = 0.0
56
+ for fut in done:
57
+ try:
58
+ h = fut.result()
59
+ except Exception:
60
+ continue
61
+ if not isinstance(h, dict) or h.get("status") != "ok":
62
+ continue
63
+ w = weights[futures[fut]]
64
+ covered += w
65
+ for x in h.get("holdings") or []:
66
+ if x.get("kind") not in ("equity", "foreign_equity") or not x.get("weight"):
67
+ continue
68
+ stocks[x["name"]] = stocks.get(x["name"], 0) + w * x["weight"]
69
+ for s in h.get("sectors") or []:
70
+ if s.get("weight"):
71
+ sectors[s["name"]] = sectors.get(s["name"], 0) + w * s["weight"]
72
+
73
+ def top(d, n):
74
+ return [f"{k} {_r(v)}%" for k, v in sorted(d.items(), key=lambda kv: -kv[1])[:n]]
75
+
76
+ return {"covered_pct": _r(covered * 100, 0), "top_stocks_of_portfolio": top(stocks, 10),
77
+ "top_sectors_of_portfolio": top(sectors, 8)}
78
+
79
+
80
+ def facts(funds_view: dict, health: dict) -> dict:
81
+ funds = [f for f in funds_view.get("funds") or [] if (f.get("value") or 0) > 0]
82
+ total = sum(f["value"] for f in funds)
83
+ invested = sum(f.get("invested") or 0 for f in funds)
84
+ weights = {f["code"]: f["value"] / total for f in funds} if total else {}
85
+ out: dict = {
86
+ "funds": len(funds),
87
+ "invested": _r(invested, 0),
88
+ "value": _r(total, 0),
89
+ "gain_pct": _r((total - invested) / invested * 100, 2) if invested else None,
90
+ "holdings": [
91
+ {"code": f["code"], "fund": _short(f.get("name")), "category": f.get("category"),
92
+ "asset_class": f.get("asset_class"), "weight_pct": _r(weights[f["code"]] * 100),
93
+ "gain_pct": _r(f.get("gain_pct"), 2),
94
+ "below_high_pct": _r(((f.get("lumpsum") or {}).get("below_high") or 0) * 100)
95
+ if (f.get("lumpsum") or {}).get("status") == "ok" else None}
96
+ for f in sorted(funds, key=lambda f: -f["value"])
97
+ ],
98
+ }
99
+ if health.get("funds"):
100
+ out["health_check"] = {
101
+ "score": health.get("score"), "grade": health.get("grade"),
102
+ "asset_classes_pct": {a["name"]: _r(a["weight"] * 100) for a in health.get("asset_classes") or []},
103
+ "categories": [f"{c['name']} {_r(c['weight'] * 100)}% ({c['count']} funds)" for c in health.get("categories") or []],
104
+ "funds_that_move_together": [f"{p['a_name']} & {p['b_name']} (correlation {_r(p['correlation'], 2)})"
105
+ for p in (health.get("similar_pairs") or [])[:5]],
106
+ "issues": list(health.get("issues") or [])[:6],
107
+ }
108
+ if weights:
109
+ lt = look_through(weights)
110
+ if (lt["covered_pct"] or 0) >= MIN_COVERAGE:
111
+ out["look_through"] = lt
112
+ else: # a few small funds' stocks would misstate the whole portfolio
113
+ out["look_through"] = {"covered_pct": lt["covered_pct"],
114
+ "note": "Holdings are known for too little of the portfolio; say nothing about its stocks or sectors."}
115
+ return out
116
+
117
+
118
+ def _system() -> str:
119
+ shape = ('{"summary": "3-4 sentences on the portfolio as a whole", "strengths": ["2-4 short points"], '
120
+ '"concerns": ["2-4 short points"], "sections": {"mix": "1-2 sentences", "overlap": "1-2 sentences", '
121
+ '"concentration": "1-2 sentences", "risk": "1-2 sentences"}, '
122
+ '"funds": [{"code": "scheme code from the data", "note": "one sentence on this fund\'s role in the portfolio"}]')
123
+ return (
124
+ "You explain a mutual fund portfolio to its owner for an analytics tool that is not registered with SEBI as "
125
+ "an investment adviser or research analyst. Using only the figures given, describe how the portfolio is made "
126
+ "up: asset mix, categories, funds that move together, look-through stocks and sectors, and how concentrated "
127
+ "it is. Gains since a recent purchase are too short to say anything about a fund. Describe observations "
128
+ "only. Never recommend buying, selling, switching, adding, merging or rebalancing, and give no view on "
129
+ "whether the portfolio or any fund is good. Reply with ONLY a JSON object: " + shape + "}"
130
+ )
131
+
132
+
133
+ def _parse(text: str, codes: set[str]) -> Optional[dict]:
134
+ from ..llm.json_reply import parse_object
135
+
136
+ data = parse_object(text)
137
+ if not data or not data.get("summary"):
138
+ return None
139
+
140
+ def points(key, n=4):
141
+ return [str(x) for x in (data.get(key) or []) if x][:n]
142
+
143
+ sections = data.get("sections") if isinstance(data.get("sections"), dict) else {}
144
+ notes = [{"code": str(f.get("code")), "note": str(f["note"])} for f in data.get("funds") or []
145
+ if isinstance(f, dict) and f.get("note") and str(f.get("code")) in codes]
146
+ out = {
147
+ "summary": str(data["summary"]),
148
+ "strengths": points("strengths"),
149
+ "concerns": points("concerns"),
150
+ "sections": {k: str(sections[k]) for k in ("mix", "overlap", "concentration", "risk") if sections.get(k)},
151
+ "funds": notes,
152
+ "view": None, # no verdict or actions (marketmyna/core/compliance.py)
153
+ "actions": [],
154
+ }
155
+ return out
156
+
157
+
158
+ def review(name: str, funds_view: dict, health: dict, config, use_cache: bool = True) -> Optional[dict]:
159
+ """The AI review, or None when the reply can't be used. Raises LookupError
160
+ with no funds; lets GenerationError through for the endpoint."""
161
+ from ..core import tenant
162
+ from ..llm.backend_factory import create_generation_backend
163
+ from ..llm.protocol import GenerationConfig
164
+
165
+ funds = [f for f in funds_view.get("funds") or [] if (f.get("value") or 0) > 0]
166
+ if not funds:
167
+ raise LookupError("This portfolio has no mutual funds to review.")
168
+ # Same user, portfolio and holdings -> same review; any trade changes the key.
169
+ holdings = ",".join(sorted(f"{f['code']}:{round(f.get('units') or 0, 3)}" for f in funds))
170
+ key = (str(tenant.current()), name, hashlib.sha1(holdings.encode()).hexdigest())
171
+ if use_cache:
172
+ with _lock:
173
+ hit = _cache.get(key)
174
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
175
+ return hit[1]
176
+
177
+ sheet = facts(funds_view, health)
178
+ result = create_generation_backend(config).generate(
179
+ "Mutual fund portfolio (JSON):\n" + json.dumps(sheet, default=str),
180
+ GenerationConfig(max_tokens=MAX_TOKENS, timeout=150), system_prompt=_system())
181
+ out = _parse(result.text, {f["code"] for f in funds})
182
+ if out is None:
183
+ text = result.text or ""
184
+ logger.warning("%s: unusable reply from %s (%d chars): %s ... %s", "Fund portfolio AI review", result.model,
185
+ len(text), " ".join(text[:150].split()), " ".join(text[-150:].split()))
186
+ return None
187
+ names = {f["code"]: _short(f.get("name")) for f in funds}
188
+ weights = {h["code"]: h["weight_pct"] for h in sheet["holdings"]}
189
+ for n in out["funds"]:
190
+ n.update(name=names.get(n["code"]), weight_pct=weights.get(n["code"]))
191
+ out.update(generated_at=time.strftime("%Y-%m-%d %H:%M"), compliance="analytics",
192
+ look_through=sheet.get("look_through"))
193
+ with _lock:
194
+ _cache[key] = (time.time(), out)
195
+ return out
@@ -0,0 +1,98 @@
1
+ """Exchange-confirmed dates for Indian stocks, from NSE's bulk feeds:
2
+
3
+ - board meetings (results, dividend, fund raising, split, bonus…), the
4
+ official results date that Yahoo's estimate is replaced with;
5
+ - corporate actions by ex-date (dividends with the amount, splits, bonus,
6
+ rights, buybacks).
7
+
8
+ One fetch covers every stock for the next four months, cached and saved on
9
+ disk through econ_calendar's source cache.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from datetime import date, datetime, timedelta
16
+ from typing import Callable, Optional
17
+
18
+ MEETINGS_URL = "https://www.nseindia.com/api/corporate-board-meetings?index=equities&from_date={a}&to_date={b}"
19
+ ACTIONS_URL = "https://www.nseindia.com/api/corporates-corporateActions?index=equities&from_date={a}&to_date={b}"
20
+ AHEAD_DAYS = 125
21
+ TTL = 6 * 3600
22
+
23
+ # words in a meeting's purpose / description -> the topic shown
24
+ TOPICS = [("financial result", "results"), ("dividend", "dividend"), ("bonus", "bonus"), ("split", "stock split"),
25
+ ("sub-division", "stock split"), ("buy back", "buyback"), ("buyback", "buyback"),
26
+ ("fund raising", "fund raising"), ("rights", "rights issue"), ("merger", "merger"),
27
+ ("amalgamation", "merger"), ("demerger", "demerger")]
28
+
29
+ # corporate action subject -> label
30
+ ACTION_LABELS = [("dividend", "Dividend"), ("bonus", "Bonus issue"), ("split", "Stock split"),
31
+ ("sub-division", "Stock split"), ("rights", "Rights issue"), ("buy back", "Buyback"),
32
+ ("buyback", "Buyback"), ("demerger", "Demerger"), ("amalgamation", "Merger"), ("agm", "AGM")]
33
+
34
+ FetchJson = Callable[[str], object]
35
+
36
+
37
+ def _day(text) -> Optional[date]:
38
+ try:
39
+ return datetime.strptime(str(text).strip(), "%d-%b-%Y").date()
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ def topics(text: str) -> list[str]:
45
+ low = (text or "").lower()
46
+ out: list[str] = []
47
+ for word, topic in TOPICS:
48
+ if word in low and topic not in out and not (topic == "merger" and "demerger" in low):
49
+ out.append(topic)
50
+ return out
51
+
52
+
53
+ def parse_meetings(rows) -> list[dict]:
54
+ """[{symbol, date, topics}], one per company and meeting day."""
55
+ merged: dict[tuple[str, str], list[str]] = {}
56
+ for r in rows or []:
57
+ d = _day(r.get("bm_date"))
58
+ sym = str(r.get("bm_symbol") or "").upper()
59
+ if not d or not sym:
60
+ continue
61
+ found = topics(f"{r.get('bm_purpose') or ''} {r.get('bm_desc') or ''}")
62
+ have = merged.setdefault((sym, d.isoformat()), [])
63
+ have += [t for t in found if t not in have]
64
+ return [{"symbol": s, "date": d, "topics": t} for (s, d), t in merged.items()]
65
+
66
+
67
+ def action_label(subject: str) -> str:
68
+ low = (subject or "").lower()
69
+ return next((label for word, label in ACTION_LABELS if word in low), "Corporate action")
70
+
71
+
72
+ def parse_actions(rows) -> list[dict]:
73
+ """[{symbol, date (ex-date), label, subject}]."""
74
+ out = []
75
+ for r in rows or []:
76
+ d = _day(r.get("exDate"))
77
+ sym = str(r.get("symbol") or "").upper()
78
+ subject = re.sub(r"\s+", " ", str(r.get("subject") or "")).strip()
79
+ if d and sym and subject:
80
+ out.append({"symbol": sym, "date": d.isoformat(), "label": action_label(subject), "subject": subject})
81
+ return out
82
+
83
+
84
+ def _fetch_json(url: str) -> object:
85
+ from .market_calendar import _get_json
86
+
87
+ return _get_json(url)
88
+
89
+
90
+ def india_events(fetch: FetchJson = _fetch_json, today: Optional[date] = None) -> dict:
91
+ """{"meetings": [...], "actions": [...]} from a few days back to four months ahead."""
92
+ from .econ_calendar import _source
93
+
94
+ today = today or date.today()
95
+ a, b = (today - timedelta(days=5)).strftime("%d-%m-%Y"), (today + timedelta(days=AHEAD_DAYS)).strftime("%d-%m-%Y")
96
+ meetings, _ = _source("nse_board_meetings", lambda: parse_meetings(fetch(MEETINGS_URL.format(a=a, b=b))), ttl=TTL)
97
+ actions, _ = _source("nse_corporate_actions", lambda: parse_actions(fetch(ACTIONS_URL.format(a=a, b=b))), ttl=TTL)
98
+ return {"meetings": meetings, "actions": actions}
@@ -0,0 +1,366 @@
1
+ """Stock overview for the Analyze page: every computed fact about one
2
+ stock - technicals, trend, returns, risk, fundamentals, analysts, events,
3
+ profile and news - without any AI call.
4
+
5
+ Each section is built independently and best-effort: a missing source
6
+ (e.g. no fundamentals for crypto) yields {"error": ...} for that section
7
+ only, never a failed page.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import math
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from typing import Callable, Optional
16
+
17
+ from ..core.numbers import finite as _num
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _pct_change(series, bars: int) -> Optional[float]:
23
+ if len(series) <= bars:
24
+ return None
25
+ past = float(series.iloc[-bars - 1])
26
+ return (float(series.iloc[-1]) / past - 1) * 100 if past else None
27
+
28
+
29
+ def _section(fn: Callable[[], dict]) -> dict:
30
+ try:
31
+ return fn()
32
+ except Exception as exc:
33
+ logger.info("Overview section failed: %s", exc)
34
+ return {"error": str(exc) or type(exc).__name__}
35
+
36
+
37
+ def _technical(history) -> dict:
38
+ from ..analysis.technical import TechnicalAnalyzer
39
+
40
+ if history is None or len(history) < 30:
41
+ raise ValueError("not enough price history for indicators")
42
+ close = history["close"].astype(float)
43
+ last = float(close.iloc[-1])
44
+ summary = TechnicalAnalyzer(history).get_summary()
45
+
46
+ notes = {
47
+ "rsi": "Momentum 0-100: below 30 oversold, above 70 overbought",
48
+ "macd": "Trend momentum; reads bullish or bearish as MACD crosses its signal line",
49
+ "ma": "Price vs 20- and 50-day averages (value shown: 20-day average)",
50
+ "bollinger": "Position inside the Bollinger band, % (0 = lower band, 100 = upper band)",
51
+ "stochastic": "Where the close sits in the recent range: below 20 oversold, above 80 overbought",
52
+ }
53
+ indicators = [
54
+ {"key": key, "name": {"rsi": "RSI (14)", "macd": "MACD", "ma": "Moving averages",
55
+ "bollinger": "Bollinger bands", "stochastic": "Stochastic %K"}.get(key, key),
56
+ "value": _num(info["value"]), "signal": info["signal"], "note": notes.get(key, "")}
57
+ for key, info in summary["indicators"].items()
58
+ ]
59
+
60
+ extra, patterns = _extra_technicals(history)
61
+ indicators += extra
62
+ counts = {"buy": sum(i["signal"] in ("buy", "strong_buy") for i in indicators),
63
+ "sell": sum(i["signal"] in ("sell", "strong_sell") for i in indicators)}
64
+ counts["neutral"] = len(indicators) - counts["buy"] - counts["sell"]
65
+ if counts["buy"] > counts["sell"] + counts["neutral"]:
66
+ overall = "buy"
67
+ elif counts["sell"] > counts["buy"] + counts["neutral"]:
68
+ overall = "sell"
69
+ else:
70
+ overall = "neutral"
71
+
72
+ averages = []
73
+ for period in (20, 50, 100, 200):
74
+ if len(close) >= period:
75
+ value = float(close.tail(period).mean())
76
+ averages.append({"period": period, "value": value, "above": last > value,
77
+ "distance_pct": (last / value - 1) * 100})
78
+ above = [a for a in averages if a["period"] in (50, 200)]
79
+ if len(above) == 2 and all(a["above"] for a in above):
80
+ trend = "uptrend"
81
+ elif len(above) == 2 and not any(a["above"] for a in above):
82
+ trend = "downtrend"
83
+ else:
84
+ trend = "sideways / mixed"
85
+
86
+ returns = {label: _pct_change(close, bars) for label, bars in
87
+ (("1W", 5), ("1M", 21), ("3M", 63), ("6M", 126), ("1Y", 250))}
88
+ window = close.tail(252)
89
+ return {
90
+ "overall": overall,
91
+ "counts": counts,
92
+ "indicators": indicators,
93
+ "patterns": patterns,
94
+ "averages": averages,
95
+ "trend": trend,
96
+ "returns": returns,
97
+ "high_52w": float(window.max()),
98
+ "low_52w": float(window.min()),
99
+ "from_high_pct": (last / float(window.max()) - 1) * 100,
100
+ "as_of": str(history.index[-1])[:10],
101
+ }
102
+
103
+
104
+ def _extra_technicals(history) -> tuple[list[dict], list[dict]]:
105
+ """Oscillators and trend gauges from marketmyna/trading/indicators.py, plus the
106
+ candlestick patterns and divergences showing on the latest bar."""
107
+ from ..trading import indicators as ind
108
+
109
+ df = history.rename(columns=str.lower).astype(float)
110
+ out: list[dict] = []
111
+
112
+ def add(key, name, value, signal, note):
113
+ value = _num(value)
114
+ if value is not None:
115
+ out.append({"key": key, "name": name, "value": value, "signal": signal, "note": note})
116
+
117
+ band = lambda v, lo, hi: "neutral" if v is None else "buy" if v <= lo else "sell" if v >= hi else "neutral" # noqa: E731
118
+ c = _num(ind.cci(df).iloc[-1]) if len(df) >= 20 else None
119
+ add("cci", "CCI (20)", c, band(c, -100, 100), "Distance from the average price: below -100 oversold, above +100 overbought")
120
+ if "volume" in df and df["volume"].fillna(0).sum() > 0 and len(df) >= 15:
121
+ m = _num(ind.mfi(df).iloc[-1])
122
+ add("mfi", "Money flow index", m, band(m, 20, 80), "Volume-weighted RSI: below 20 oversold, above 80 overbought")
123
+ w = _num(ind.williams_r(df).iloc[-1]) if len(df) >= 14 else None
124
+ add("williams_r", "Williams %R", w, band(w, -80, -20), "Close vs 14-day range: below -80 oversold, above -20 overbought")
125
+ if len(df) >= 30:
126
+ a = ind.adx(df).iloc[-1]
127
+ strength = _num(a["adx"])
128
+ sig = "neutral" if strength is None or strength < 25 else "buy" if a["plus_di"] > a["minus_di"] else "sell"
129
+ add("adx", "ADX (14)", strength, sig, "Trend strength: above 25 is a trending market; direction from +DI vs -DI")
130
+ ar = _num(ind.aroon(df)["osc"].iloc[-1])
131
+ add("aroon", "Aroon oscillator", ar, "neutral" if ar is None else "buy" if ar >= 50 else "sell" if ar <= -50 else "neutral",
132
+ "Recency of 25-day highs vs lows: above +50 uptrend, below -50 downtrend")
133
+ if len(df) >= 80:
134
+ ich = ind.ichimoku(df).iloc[-1]
135
+ last = float(df["close"].iloc[-1])
136
+ top, bottom = max(ich["span_a"], ich["span_b"]), min(ich["span_a"], ich["span_b"])
137
+ sig = "buy" if last > top else "sell" if last < bottom else "neutral"
138
+ add("ichimoku", "Ichimoku cloud", (last / top - 1) * 100 if last > top else (last / bottom - 1) * 100 if last < bottom else 0.0,
139
+ sig, "Price vs the cloud, % outside it: above = bullish, inside = undecided, below = bearish")
140
+
141
+ patterns = [{"key": k, "name": ind.CANDLE_PATTERNS[k][0], "bias": ind.CANDLE_PATTERNS[k][1]}
142
+ for k in ind.candle_patterns(df)]
143
+ if len(df) >= 40:
144
+ for label, osc in (("RSI", ind.rsi(df["close"])), ("MACD", ind.macd(df["close"])["macd"])):
145
+ kind = ind.divergence(df, osc)
146
+ if kind:
147
+ patterns.append({"key": f"{label.lower()}_{kind}_div", "name": f"{label} {kind} divergence", "bias": kind})
148
+ return out, patterns
149
+
150
+
151
+ def _risk(history, info: dict) -> dict:
152
+ if history is None or len(history) < 30:
153
+ raise ValueError("not enough price history for risk")
154
+ close = history["close"].astype(float)
155
+ returns = close.pct_change().dropna()
156
+ volatility = float(returns.std() * math.sqrt(252))
157
+ drawdown = float(((close - close.cummax()) / close.cummax()).min())
158
+ level = "high" if volatility > 0.5 or drawdown < -0.35 else "medium" if volatility > 0.25 or drawdown < -0.2 else "low"
159
+ return {
160
+ "level": level,
161
+ "volatility_pct": volatility * 100,
162
+ "max_drawdown_pct": drawdown * 100,
163
+ "beta": _num(info.get("beta")),
164
+ "worst_day_pct": float(returns.min() * 100),
165
+ "best_day_pct": float(returns.max() * 100),
166
+ "realized": _realized(history),
167
+ }
168
+
169
+
170
+ def _realized(history) -> Optional[dict]:
171
+ from ..trading.volatility import summary
172
+
173
+ try:
174
+ return summary(history.rename(columns=str.lower))
175
+ except Exception as exc:
176
+ logger.info("Realized volatility unavailable: %s", exc)
177
+ return None
178
+
179
+
180
+ def _fundamentals(info: dict) -> dict:
181
+ from ..analysis.fundamental import FundamentalAnalyzer, yfinance_fundamentals
182
+
183
+ if not info or not (info.get("marketCap") or info.get("trailingPE") or info.get("totalRevenue")):
184
+ raise ValueError("no fundamentals published for this instrument")
185
+ norm = yfinance_fundamentals(info)
186
+ summary = FundamentalAnalyzer(norm).get_summary()
187
+
188
+ def pct(key):
189
+ v = norm.get(key)
190
+ return v * 100 if v is not None else None
191
+
192
+ return {
193
+ "score": summary.get("overall_score"),
194
+ "valuation_status": (summary.get("valuation") or {}).get("status"),
195
+ "health_rating": (summary.get("financial_health") or {}).get("rating"),
196
+ "currency": info.get("financialCurrency") or info.get("currency"),
197
+ "groups": [
198
+ {"title": "Valuation", "items": [
199
+ {"label": "Market cap", "value": _num(info.get("marketCap")), "kind": "money"},
200
+ {"label": "P/E (trailing)", "value": norm.get("pe_ratio"), "kind": "ratio"},
201
+ {"label": "P/E (forward)", "value": norm.get("forward_pe"), "kind": "ratio"},
202
+ {"label": "PEG", "value": norm.get("peg_ratio"), "kind": "ratio"},
203
+ {"label": "Price / book", "value": norm.get("price_to_book"), "kind": "ratio"},
204
+ {"label": "Price / sales", "value": norm.get("price_to_sales"), "kind": "ratio"},
205
+ {"label": "EV / EBITDA", "value": _num(info.get("enterpriseToEbitda")), "kind": "ratio"},
206
+ ]},
207
+ {"title": "Profitability", "items": [
208
+ {"label": "Return on equity", "value": pct("return_on_equity"), "kind": "pct"},
209
+ {"label": "Return on assets", "value": pct("return_on_assets"), "kind": "pct"},
210
+ {"label": "Profit margin", "value": pct("profit_margin"), "kind": "pct"},
211
+ {"label": "Operating margin", "value": _num(info.get("operatingMargins")) and info["operatingMargins"] * 100, "kind": "pct"},
212
+ {"label": "EPS (trailing)", "value": _num(info.get("trailingEps")), "kind": "ratio"},
213
+ ]},
214
+ {"title": "Growth", "items": [
215
+ {"label": "Revenue growth (YoY)", "value": pct("revenue_growth"), "kind": "pct"},
216
+ {"label": "Earnings growth (YoY)", "value": pct("earnings_growth"), "kind": "pct"},
217
+ {"label": "Revenue (TTM)", "value": _num(info.get("totalRevenue")), "kind": "money"},
218
+ ]},
219
+ {"title": "Financial health", "items": [
220
+ {"label": "Debt / equity", "value": norm.get("debt_to_equity"), "kind": "ratio"},
221
+ {"label": "Current ratio", "value": norm.get("current_ratio"), "kind": "ratio"},
222
+ {"label": "Total cash", "value": _num(info.get("totalCash")), "kind": "money"},
223
+ {"label": "Total debt", "value": _num(info.get("totalDebt")), "kind": "money"},
224
+ {"label": "Free cash flow", "value": _num(info.get("freeCashflow")), "kind": "money"},
225
+ ]},
226
+ {"title": "Dividend", "items": [
227
+ {"label": "Dividend yield", "value": pct("dividend_yield"), "kind": "pct"},
228
+ {"label": "Payout ratio", "value": pct("payout_ratio"), "kind": "pct"},
229
+ {"label": "Dividend per share", "value": _num(info.get("dividendRate")), "kind": "ratio"},
230
+ ]},
231
+ ],
232
+ }
233
+
234
+
235
+ def _analysts(info: dict) -> dict:
236
+ if not info.get("recommendationKey") and not info.get("targetMeanPrice"):
237
+ raise ValueError("no analyst coverage published")
238
+ return {
239
+ "recommendation": info.get("recommendationKey"),
240
+ "mean_score": _num(info.get("recommendationMean")), # 1 = strong buy ... 5 = sell
241
+ "count": info.get("numberOfAnalystOpinions"),
242
+ "target_mean": _num(info.get("targetMeanPrice")),
243
+ "target_high": _num(info.get("targetHighPrice")),
244
+ "target_low": _num(info.get("targetLowPrice")),
245
+ }
246
+
247
+
248
+ def _events(yf_symbol: str) -> dict:
249
+ import yfinance as yf
250
+
251
+ from .analysis_run import _to_str_date
252
+
253
+ cal = yf.Ticker(yf_symbol).calendar or {}
254
+ out = {
255
+ "earnings_date": _to_str_date(cal.get("Earnings Date")),
256
+ "ex_dividend_date": _to_str_date(cal.get("Ex-Dividend Date")),
257
+ "dividend_date": _to_str_date(cal.get("Dividend Date")),
258
+ "eps_estimate": _num(cal.get("Earnings Average")),
259
+ "revenue_estimate": _num(cal.get("Revenue Average")),
260
+ }
261
+ if not any(out.values()):
262
+ raise ValueError("no upcoming events published")
263
+ return out
264
+
265
+
266
+ def _profile(info: dict, market: str) -> dict:
267
+ if not info:
268
+ raise ValueError("no company profile available")
269
+ return {
270
+ "name": info.get("longName") or info.get("shortName"),
271
+ "sector": info.get("sector"),
272
+ "industry": info.get("industry"),
273
+ "country": info.get("country"),
274
+ "employees": info.get("fullTimeEmployees"),
275
+ "website": info.get("website"),
276
+ "summary": info.get("longBusinessSummary"),
277
+ "exchange": info.get("exchange") or info.get("fullExchangeName"),
278
+ "currency": info.get("currency"),
279
+ }
280
+
281
+
282
+ def build_overview(symbol: str, market: str) -> dict:
283
+ """Technicals, risk, fundamentals, analysts, events and news for one
284
+ stock, without per-stock verdicts (public_view)."""
285
+ return public_view(_build_overview(symbol, market))
286
+
287
+
288
+ def public_view(out: dict) -> dict:
289
+ """What each indicator reads (bullish / bearish / neutral) instead of a
290
+ buy / sell call, the overall reading and its counts, and the pattern
291
+ lean; no "overvalued / undervalued" label (docs/future/sebi-compliance-mode.md,
292
+ step 10). Trend, moving averages and returns stay; the attributed broker
293
+ consensus is shown as bullish / neutral / bearish, without price targets."""
294
+ tech = dict(out.get("technical") or {})
295
+ if "indicators" in tech:
296
+ tech["indicators"] = [{**i, "signal": reading(i.get("signal"))} for i in tech["indicators"]]
297
+ if "overall" in tech:
298
+ tech["overall"] = reading(tech["overall"])
299
+ fund = dict(out.get("fundamentals") or {})
300
+ fund.pop("valuation_status", None)
301
+ # Third-party consensus as a direction, not a call, and without the brokers' price targets.
302
+ analysts = {k: v for k, v in (out.get("analysts") or {}).items() if k not in ("target_mean", "target_high", "target_low")}
303
+ if analysts.get("recommendation"):
304
+ analysts["recommendation"] = reading(analysts["recommendation"]) or analysts["recommendation"]
305
+ return {**out, "technical": tech, "fundamentals": fund, "analysts": analysts, "compliance": "analytics"}
306
+
307
+
308
+ _READINGS = {"strong_buy": "bullish", "buy": "bullish", "bullish": "bullish", "outperform": "bullish",
309
+ "strong_sell": "bearish", "sell": "bearish", "bearish": "bearish", "underperform": "bearish",
310
+ "hold": "neutral", "neutral": "neutral"}
311
+
312
+
313
+ def reading(signal):
314
+ """A buy / sell signal as what the indicator reads, not a call."""
315
+ return _READINGS.get(str(signal).lower()) if signal else None
316
+
317
+
318
+ def _build_overview(symbol: str, market: str) -> dict:
319
+ from ..agent.agents import _resolve_fetcher
320
+ from ..data.news_search import stock_news
321
+ from .analysis_run import yahoo_symbol
322
+
323
+ yf_symbol = yahoo_symbol(symbol, market)
324
+
325
+ def load_history():
326
+ return _resolve_fetcher(market).get_history(symbol, period="1y")
327
+
328
+ def load_info():
329
+ if market == "crypto":
330
+ return {}
331
+ import yfinance as yf
332
+
333
+ return yf.Ticker(yf_symbol).info or {}
334
+
335
+ # History, company info and news are independent network calls.
336
+ with ThreadPoolExecutor(max_workers=3) as pool:
337
+ history_f = pool.submit(load_history)
338
+ info_f = pool.submit(load_info)
339
+ news_f = pool.submit(lambda: stock_news(symbol, market, limit=8))
340
+ try:
341
+ history = history_f.result()
342
+ except Exception as exc:
343
+ logger.info("Overview history for %s failed: %s", symbol, exc)
344
+ history = None
345
+ try:
346
+ info = info_f.result()
347
+ except Exception as exc:
348
+ logger.info("Overview info for %s failed: %s", symbol, exc)
349
+ info = {}
350
+ try:
351
+ news = news_f.result()
352
+ except Exception as exc:
353
+ news = {"error": str(exc)}
354
+
355
+ not_applicable = {"error": "not applicable to crypto"}
356
+ return {
357
+ "symbol": symbol,
358
+ "market": market,
359
+ "technical": _section(lambda: _technical(history)),
360
+ "risk": _section(lambda: _risk(history, info)),
361
+ "fundamentals": not_applicable if market == "crypto" else _section(lambda: _fundamentals(info)),
362
+ "analysts": not_applicable if market == "crypto" else _section(lambda: _analysts(info)),
363
+ "events": not_applicable if market == "crypto" else _section(lambda: _events(yf_symbol)),
364
+ "profile": not_applicable if market == "crypto" else _section(lambda: _profile(info, market)),
365
+ "news": news if isinstance(news, dict) else {"items": news},
366
+ }