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,181 @@
1
+ """AI review of any mutual fund on the Analyze page.
2
+
3
+ Reads the same figures the fund page shows (rolling returns and risk against
4
+ the category benchmark, rank among competitors, style, holdings, Direct vs
5
+ Regular cost), condenses them into a short fact sheet and asks the model to
6
+ explain them. Wording follows marketmyna/core/compliance.py: the owner gets a view,
7
+ public users an explanation without a recommendation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import threading
15
+ import time
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from typing import Optional
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ CACHE_SECONDS = 6 * 3600 # the inputs change monthly (holdings) or daily (NAV)
22
+ # Reasoning models (gpt-oss, Gemini thinking) spend part of max_tokens before
23
+ # answering; too tight a budget cuts the JSON off mid-way.
24
+ MAX_TOKENS = 3000
25
+ _cache: dict[tuple, tuple[float, dict]] = {}
26
+ _lock = threading.Lock()
27
+
28
+
29
+
30
+ def _r(x, nd=1):
31
+ return round(x, nd) if isinstance(x, (int, float)) else None
32
+
33
+
34
+ def _pct(x, nd=1):
35
+ """A 0-1 fraction as a percentage."""
36
+ return round(x * 100, nd) if isinstance(x, (int, float)) else None
37
+
38
+
39
+ def facts(code: str) -> dict:
40
+ """The fund's numbers, trimmed to what an explanation needs. Each part is
41
+ optional: a source that fails just leaves its section out."""
42
+ from . import fund_analytics, fund_cost, fund_holdings, fund_peers, fund_style
43
+
44
+ parts = {"analytics": fund_analytics.build, "peers": fund_peers.compare, "style": fund_style.build,
45
+ "cost": fund_cost.build, "holdings": fund_holdings.for_fund}
46
+
47
+ def run(fn):
48
+ try:
49
+ out = fn(code)
50
+ return out if isinstance(out, dict) and out.get("status") == "ok" else None
51
+ except Exception as exc:
52
+ logger.info("Fund AI facts: %s failed for %s: %s", fn.__module__, code, exc)
53
+ return None
54
+
55
+ with ThreadPoolExecutor(max_workers=len(parts)) as pool:
56
+ got = dict(zip(parts, pool.map(run, parts.values())))
57
+
58
+ out: dict = {"code": code}
59
+ peers, a, st, cost, h = got["peers"], got["analytics"], got["style"], got["cost"], got["holdings"]
60
+ if peers:
61
+ out.update(name=peers.get("name"), category=peers.get("category"), plan=peers.get("plan"))
62
+ out["rank_among_peers"] = {
63
+ k: {"rank": v["rank"], "of": v["of"]} for k, v in (peers.get("ranks") or {}).items() if v and v.get("rank")
64
+ }
65
+ out["rank_notes"] = "r1/r3/r5 = 1/3/5-year return; volatility and drawdown rank 1 = least risky"
66
+ if a:
67
+ out.setdefault("category", a.get("category"))
68
+ out["nav_history_from"] = a.get("nav_from")
69
+ out["benchmark"] = (a.get("benchmark") or {}).get("name")
70
+ out["rolling_returns_pct"] = [
71
+ {"years": w["years"], "median": _r(w.get("median")), "benchmark_median": _r(w.get("bench_median")),
72
+ "beat_benchmark_pct_of_windows": _r(w.get("beat_pct")), "negative_pct_of_windows": _r(w.get("negative_pct")),
73
+ "worst": _r(w.get("min")), "best": _r(w.get("max"))}
74
+ for w in (a.get("rolling") or {}).get("windows", [])
75
+ ]
76
+ r = a.get("risk") or {}
77
+ if r:
78
+ out[f"risk_last_{r.get('years', 3)}y"] = {
79
+ "annual_return_pct": _pct(r.get("annual_return")), "benchmark_return_pct": _pct(r.get("bench_annual_return")),
80
+ "volatility_pct": _pct(r.get("volatility")), "benchmark_volatility_pct": _pct(r.get("bench_volatility")),
81
+ "max_drawdown_pct": _pct(r.get("max_drawdown")), "benchmark_max_drawdown_pct": _pct(r.get("bench_max_drawdown")),
82
+ "sharpe": _r(r.get("sharpe"), 2), "sortino": _r(r.get("sortino"), 2), "beta": _r(r.get("beta"), 2),
83
+ "alpha_pct": _pct(r.get("alpha")), "up_capture_pct": _pct(r.get("up_capture"), 0),
84
+ "down_capture_pct": _pct(r.get("down_capture"), 0),
85
+ }
86
+ d = a.get("drawdowns") or {}
87
+ if d:
88
+ out["drawdown_pct"] = {"now_from_peak": _r(d.get("current")), "worst_ever": _r(d.get("max"))}
89
+ if st:
90
+ out["style"] = {
91
+ "label": (st.get("style") or {}).get("label"), "size": st.get("size"),
92
+ "pe": _r((st.get("fund") or {}).get("pe")), "market_pe": _r((st.get("market") or {}).get("pe")),
93
+ "equity_pct": _r(st.get("equity_pct")), "cap_split_pct": {k: _r(v) for k, v in (st.get("split") or {}).items() if v},
94
+ "category_rules_failed": [c["rule"] for c in st.get("checks") or [] if not c.get("ok")],
95
+ }
96
+ if h:
97
+ out["portfolio"] = {
98
+ "as_of": h.get("as_of"), "aum_crore": _r(h.get("aum_cr"), 0), "stocks": h.get("count"),
99
+ "top10_weight_pct": _r(h.get("top10_weight")),
100
+ "effective_stocks": _r((h.get("concentration") or {}).get("effective_stocks"), 0),
101
+ "top_holdings": [f"{x['name']} {_r(x.get('weight'))}%" for x in (h.get("top") or [])[:5]],
102
+ "top_sectors": [f"{x['name']} {_r(x.get('weight'))}%" for x in (h.get("sectors") or [])[:5]],
103
+ "asset_mix": [f"{x['kind']} {_r(x.get('weight'))}%" for x in (h.get("mix") or [])[:4]],
104
+ }
105
+ out.setdefault("name", h.get("scheme"))
106
+ if cost:
107
+ p = cost.get("plans") or {}
108
+ if p.get("gap") is not None:
109
+ out["direct_vs_regular"] = {"years": p.get("years"), "direct_return_pct": _r(p.get("direct")),
110
+ "regular_return_pct": _r(p.get("regular")), "yearly_gap_pct": _r(p.get("gap"), 2)}
111
+ out["tax"] = (cost.get("tax") or {}).get("text")
112
+ return out
113
+
114
+
115
+ def _system() -> str:
116
+ shape = ('{"summary": "3-4 sentences", "strengths": ["2-4 short points"], "concerns": ["2-4 short points"], '
117
+ '"sections": {"returns": "1-2 sentences", "risk": "1-2 sentences", "portfolio": "1-2 sentences", '
118
+ '"peers": "1-2 sentences", "cost": "1 sentence"}')
119
+ return (
120
+ "You explain mutual fund data to retail investors for an analytics tool that is not registered with SEBI as "
121
+ "an investment adviser or research analyst. Using only the figures given, explain how the fund has behaved: "
122
+ "returns over rolling periods against its benchmark, risk, where it ranks among competitors, what it holds "
123
+ "and what it costs. Describe observations only. Never recommend investing, buying, holding, switching or "
124
+ "selling, give no view on whether it is a good choice, and do not call it attractive, cheap, expensive, "
125
+ "undervalued or overvalued. Reply with ONLY a JSON object: " + shape
126
+ )
127
+
128
+
129
+ def _parse(text: str) -> Optional[dict]:
130
+ from ..llm.json_reply import parse_object
131
+
132
+ data = parse_object(text)
133
+ if not data or not data.get("summary"):
134
+ return None
135
+
136
+ def points(key):
137
+ return [str(x) for x in (data.get(key) or []) if x][:4]
138
+
139
+ sections = data.get("sections") if isinstance(data.get("sections"), dict) else {}
140
+ out = {
141
+ "summary": str(data["summary"]),
142
+ "strengths": points("strengths"),
143
+ "concerns": points("concerns"),
144
+ "sections": {k: str(sections[k]) for k in ("returns", "risk", "portfolio", "peers", "cost") if sections.get(k)},
145
+ "view": None, # no verdict on the fund (marketmyna/core/compliance.py)
146
+ "suits": None,
147
+ }
148
+ return out
149
+
150
+
151
+ def review(code: str, config, use_cache: bool = True) -> Optional[dict]:
152
+ """The AI review, or None when the model's reply can't be used. Raises
153
+ LookupError when there is no fund data at all, and lets GenerationError
154
+ (no key / quota) through for the endpoint to report."""
155
+ from ..llm.backend_factory import create_generation_backend
156
+ from ..llm.protocol import GenerationConfig
157
+
158
+ key = code
159
+ if use_cache:
160
+ with _lock:
161
+ hit = _cache.get(key)
162
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
163
+ return hit[1]
164
+
165
+ sheet = facts(code)
166
+ if len(sheet) <= 2: # nothing beyond the code (and maybe a name)
167
+ raise LookupError("No data for this fund yet, so there is nothing to review.")
168
+ prompt = "Fund data (JSON):\n" + json.dumps(sheet, default=str)
169
+ result = create_generation_backend(config).generate(
170
+ prompt, GenerationConfig(max_tokens=MAX_TOKENS, timeout=120), system_prompt=_system())
171
+ out = _parse(result.text)
172
+ if out is None:
173
+ text = result.text or ""
174
+ logger.warning("%s: unusable reply from %s (%d chars): %s ... %s", "Fund AI review", result.model,
175
+ len(text), " ".join(text[:150].split()), " ".join(text[-150:].split()))
176
+ return None
177
+ out.update(name=sheet.get("name"), category=sheet.get("category"), generated_at=time.strftime("%Y-%m-%d %H:%M"),
178
+ compliance="analytics")
179
+ with _lock:
180
+ _cache[key] = (time.time(), out)
181
+ return out
@@ -0,0 +1,371 @@
1
+ """Deeper analysis of one mutual fund from its daily NAV history:
2
+
3
+ rolling CAGR over every 1 / 3 / 5-year window (one per start day),
4
+ its spread, and how often the fund beat its benchmark over the
5
+ same windows - what point-to-point returns hide.
6
+ risk last 3 years against the category benchmark: beta, alpha,
7
+ Sharpe, Sortino, tracking error, information ratio, up / down
8
+ capture (same maths as the portfolio page).
9
+ drawdowns the deepest falls from a high, how long the recovery took,
10
+ and how the fund did in the 2020 and 2022 sell-offs.
11
+
12
+ The benchmark follows SEBI's tier-1 index for the category (Nifty 100 for
13
+ large caps, Midcap 150, Smallcap 250, Nifty 500 for the broad equity
14
+ categories); hybrid, debt and fund-of-funds categories have no free index and
15
+ are shown without one. A NAV includes reinvested dividends but Yahoo's index
16
+ series are price-only, so each index gets its dividends back as a steady
17
+ yearly rate: the gap between the index and an ETF tracking it (an ETF's
18
+ dividend-adjusted price follows the total-return index), measured over the
19
+ years both exist. Indices without a long-lived ETF borrow a sister index's gap.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import math
26
+ import threading
27
+ import time
28
+ from typing import Callable, Optional
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from ..core.numbers import finite
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ CACHE_SECONDS = 12 * 3600
38
+ WINDOWS = (1, 3, 5) # rolling-return windows, years
39
+ RISK_YEARS = 3
40
+ RISK_FREE = 0.065 # Indian T-bill ballpark, a year
41
+ TRADING_DAYS = 252
42
+ TOP_DRAWDOWNS = 5
43
+ MIN_DRAWDOWN = 0.05 # falls shallower than 5% are not listed
44
+ EPISODES = [ # (label, from, to): market sell-offs to compare the fund in
45
+ ("2020 Covid crash", "2020-01-14", "2020-03-31"),
46
+ ("2022 rate-rise sell-off", "2021-10-01", "2022-10-31"),
47
+ ("2024-25 correction", "2024-09-26", "2025-03-04"),
48
+ ]
49
+
50
+ NIFTY_50 = ("^NSEI", "Nifty 50")
51
+ NIFTY_100 = ("^CNX100", "Nifty 100")
52
+ NIFTY_500 = ("^CRSLDX", "Nifty 500")
53
+ NEXT_50 = ("^NSMIDCP", "Nifty Next 50")
54
+ MIDCAP_150 = ("NIFTYMIDCAP150.NS", "Nifty Midcap 150")
55
+ MIDCAP_50 = ("^NSEMDCP50", "Nifty Midcap 50")
56
+ SMALLCAP_250 = ("NIFTYSMLCAP250.NS", "Nifty Smallcap 250")
57
+ SENSEX = ("^BSESN", "Sensex")
58
+
59
+ # Index funds / ETFs: the index named in the scheme (checked in order).
60
+ INDEX_NAMES = [("next 50", NEXT_50), ("midcap 150", MIDCAP_150), ("midcap 50", MIDCAP_50),
61
+ ("smallcap 250", SMALLCAP_250), ("nifty 500", NIFTY_500), ("nifty 100", NIFTY_100),
62
+ ("nifty 50", NIFTY_50), ("sensex", SENSEX)]
63
+ BROAD_EQUITY = ("flexi cap", "multi cap", "focused", "elss", "value", "contra", "dividend yield", "sectoral",
64
+ "thematic", "equity funds", "growth (close ended)")
65
+
66
+ # price index -> ETF whose dividend-adjusted price gives that index's dividend yield
67
+ # (the most liquid, closest-tracking ETFs; smaller ones are too noisy)
68
+ TR_PROXY = {"^NSEI": "NIFTYBEES.NS", "NIFTYMIDCAP150.NS": "MID150BEES.NS"}
69
+ # other indices borrow the yield of the closest of those two
70
+ TR_SISTER = {"^CNX100": "^NSEI", "^CRSLDX": "^NSEI", "^NSMIDCP": "^NSEI", "^BSESN": "^NSEI",
71
+ "NIFTYSMLCAP250.NS": "NIFTYMIDCAP150.NS", "^NSEMDCP50": "NIFTYMIDCAP150.NS"}
72
+ YIELD_YEARS = 5 # recent years only: ETF fees were higher a decade ago
73
+ MAX_YIELD = 0.025 # a dividend gap above 2.5% a year is a data problem
74
+ JUMP = 0.4 # a one-day log move this big in a NAV is a data break, not a market move
75
+
76
+ _cache: dict[str, tuple[float, dict]] = {}
77
+ _lock = threading.Lock()
78
+
79
+ NavFetch = Callable[[str], Optional[pd.Series]]
80
+ BenchFetch = Callable[[str], Optional[pd.Series]]
81
+
82
+
83
+ def benchmark_for(category: Optional[str], name: str = "") -> Optional[tuple[str, str]]:
84
+ """(Yahoo ticker, label) of the index to judge this fund against, or None."""
85
+ cat, nm = (category or "").lower(), (name or "").lower()
86
+ if "index" in cat or "etf" in cat:
87
+ return next((b for key, b in INDEX_NAMES if key in nm), None)
88
+ if "large & mid" in cat:
89
+ return NIFTY_500 # Nifty LargeMidcap 250 has no free daily series
90
+ if "large cap" in cat:
91
+ return NIFTY_100
92
+ if "mid cap" in cat:
93
+ return MIDCAP_150
94
+ if "small cap" in cat:
95
+ return SMALLCAP_250
96
+ if any(k in cat for k in BROAD_EQUITY):
97
+ return NIFTY_500
98
+ return None
99
+
100
+
101
+ def clean_nav(nav: pd.Series) -> pd.Series:
102
+ """Fix NAV data breaks: a one-day spike that reverts is dropped; a lasting
103
+ jump (face-value change, e.g. 28.56 -> 2857.24) rescales the history before
104
+ it, so returns across the change stay right."""
105
+ s = nav.astype(float)
106
+ s = s[s > 0].sort_index()
107
+ s = s[~s.index.duplicated(keep="last")]
108
+ for _ in range(10):
109
+ lr = np.log(s / s.shift(1))
110
+ jumps = lr[lr.abs() > JUMP]
111
+ if jumps.empty:
112
+ break
113
+ i = s.index.get_loc(jumps.index[0])
114
+ nxt = float(np.log(s.iloc[i + 1] / s.iloc[i])) if i + 1 < len(s) else 0.0
115
+ if abs(nxt) > JUMP and np.sign(nxt) != np.sign(jumps.iloc[0]):
116
+ s = s.drop(s.index[i])
117
+ else:
118
+ s.iloc[:i] = s.iloc[:i] * (s.iloc[i] / s.iloc[i - 1])
119
+ return s
120
+
121
+
122
+ def _daily(s: pd.Series) -> pd.Series:
123
+ """Calendar-daily, forward-filled: lets a window be exactly N years."""
124
+ s = s.sort_index()
125
+ s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
126
+ s = s[~s.index.duplicated(keep="last")]
127
+ return s.asfreq("D").ffill()
128
+
129
+
130
+ def _cagr_series(daily: pd.Series, years: int) -> pd.Series:
131
+ """CAGR (%) of the window starting on each day, indexed by start day."""
132
+ days = round(365.25 * years)
133
+ end = daily.shift(-days)
134
+ return ((end / daily) ** (1 / years) - 1).dropna() * 100
135
+
136
+
137
+ def rolling(nav: pd.Series, bench: Optional[pd.Series] = None) -> dict:
138
+ fund = _daily(nav)
139
+ idx = _daily(bench) if bench is not None and len(bench) else None
140
+ out = []
141
+ for years in WINDOWS:
142
+ f = _cagr_series(fund, years)
143
+ if len(f) < 30:
144
+ continue
145
+ row = {"years": years, "windows": int(len(f)), "from": str(f.index[0].date()),
146
+ "median": float(f.median()), "mean": float(f.mean()), "min": float(f.min()), "max": float(f.max()),
147
+ "worst_start": str(f.idxmin().date()), "best_start": str(f.idxmax().date()),
148
+ "negative_pct": float((f < 0).mean() * 100),
149
+ "above_8_pct": float((f > 8).mean() * 100), "above_12_pct": float((f > 12).mean() * 100),
150
+ "bench_median": None, "beat_pct": None, "compared": 0}
151
+ if idx is not None:
152
+ b = _cagr_series(idx, years)
153
+ pair = pd.concat([f, b], axis=1, keys=["f", "b"]).dropna()
154
+ if len(pair) >= 30:
155
+ row.update(bench_median=float(pair["b"].median()), compared=int(len(pair)),
156
+ beat_pct=float((pair["f"] > pair["b"]).mean() * 100))
157
+ out.append(row)
158
+ # 3-year rolling CAGR (or the longest window there is) for a chart, weekly points
159
+ chart_years = next((y for y in (3, 1) if any(r["years"] == y for r in out)), None)
160
+ chart = []
161
+ if chart_years:
162
+ f = _cagr_series(fund, chart_years).resample("W").last().dropna()
163
+ b = _cagr_series(idx, chart_years).resample("W").last() if idx is not None else None
164
+ for day, v in f.items():
165
+ bv = b.get(day) if b is not None else None
166
+ chart.append({"date": str(day.date()), "fund": round(float(v), 2),
167
+ "bench": None if bv is None or pd.isna(bv) else round(float(bv), 2)})
168
+ return {"windows": out, "chart_years": chart_years, "chart": chart}
169
+
170
+
171
+ def _returns(s: pd.Series, since: pd.Timestamp) -> pd.Series:
172
+ s = s.sort_index()
173
+ s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
174
+ s = s[~s.index.duplicated(keep="last")]
175
+ return s[s.index >= since].pct_change().dropna()
176
+
177
+
178
+ def risk(nav: pd.Series, bench: Optional[pd.Series] = None) -> Optional[dict]:
179
+ """Last RISK_YEARS of daily returns (business days both series traded)."""
180
+ from ..portfolio.analytics import ratios, versus_benchmark
181
+
182
+ end = pd.DatetimeIndex(nav.index).tz_localize(None).max()
183
+ since = end - pd.DateOffset(years=RISK_YEARS)
184
+ fund = _returns(nav, since)
185
+ if len(fund) < 120:
186
+ return None
187
+ out = {"years": RISK_YEARS, "from": str(fund.index[0].date()), "risk_free": RISK_FREE * 100,
188
+ **ratios(fund, RISK_FREE), "beta": None, "alpha": None, "correlation": None,
189
+ "bench_annual_return": None, "bench_volatility": None, "bench_max_drawdown": None}
190
+ if bench is not None and len(bench):
191
+ b = _returns(bench, since)
192
+ pair = pd.concat([fund, b], axis=1, keys=["f", "b"]).dropna()
193
+ if len(pair) >= 120:
194
+ bvar = float(pair["b"].var())
195
+ beta = float(pair["f"].cov(pair["b"]) / bvar) if bvar else None
196
+ bstats = ratios(pair["b"], RISK_FREE)
197
+ fann = float((1 + pair["f"]).prod()) ** (TRADING_DAYS / len(pair)) - 1
198
+ alpha = (fann - RISK_FREE - beta * (bstats["annual_return"] - RISK_FREE)) if beta is not None else None
199
+ out.update(versus_benchmark(pair["f"], pair["b"]))
200
+ out.update(beta=finite(beta), alpha=finite(alpha), correlation=finite(pair["f"].corr(pair["b"])),
201
+ bench_annual_return=bstats["annual_return"], bench_volatility=bstats["volatility"],
202
+ bench_max_drawdown=bstats["max_drawdown"])
203
+ return out
204
+
205
+
206
+ def _episodes(curve: pd.Series) -> list[dict]:
207
+ """Every fall from a high, deepest first: peak, trough, depth, recovery."""
208
+ curve = curve.dropna()
209
+ peak = curve.cummax()
210
+ under = curve / peak - 1
211
+ out, start = [], None
212
+ for day, dd in under.items():
213
+ if dd < -1e-9 and start is None:
214
+ start = day
215
+ elif dd >= -1e-9 and start is not None: # back at the old high (allowing float noise)
216
+ out.append((start, day))
217
+ start = None
218
+ if start is not None:
219
+ out.append((start, None))
220
+ rows = []
221
+ for begin, back in out:
222
+ span = under[begin:back] if back is not None else under[begin:]
223
+ trough_day = span.idxmin()
224
+ depth = float(span.min())
225
+ if -depth < MIN_DRAWDOWN:
226
+ continue
227
+ peak_day = curve[:begin].index[-2] if len(curve[:begin]) > 1 else begin
228
+ rows.append({"peak": str(peak_day.date()), "trough": str(trough_day.date()),
229
+ "recovered": str(back.date()) if back is not None else None, "depth": depth * 100,
230
+ "days_down": int((trough_day - peak_day).days),
231
+ "days_to_recover": int((back - trough_day).days) if back is not None else None})
232
+ rows.sort(key=lambda r: r["depth"])
233
+ return rows[:TOP_DRAWDOWNS]
234
+
235
+
236
+ def _window_move(s: pd.Series, start: str, end: str) -> Optional[float]:
237
+ """Worst peak-to-trough fall (%) inside the window."""
238
+ w = s[(s.index >= start) & (s.index <= end)].dropna()
239
+ if len(w) < 10:
240
+ return None
241
+ return float((w / w.cummax() - 1).min() * 100)
242
+
243
+
244
+ def drawdowns(nav: pd.Series, bench: Optional[pd.Series] = None) -> dict:
245
+ fund = _daily(nav)
246
+ under = (fund / fund.cummax() - 1) * 100
247
+ idx = _daily(bench) if bench is not None and len(bench) else None
248
+ weekly = under.resample("W").min().dropna()
249
+ bweekly = None
250
+ if idx is not None:
251
+ bunder = (idx / idx.cummax() - 1) * 100
252
+ bweekly = bunder[bunder.index >= fund.index[0]].resample("W").min()
253
+ chart = [{"date": str(d.date()), "fund": round(float(v), 2),
254
+ "bench": None if bweekly is None or pd.isna(bweekly.get(d)) else round(float(bweekly.get(d)), 2)}
255
+ for d, v in weekly.items()]
256
+ crashes = []
257
+ for label, start, end in EPISODES:
258
+ f = _window_move(fund, start, end)
259
+ if f is None:
260
+ continue
261
+ crashes.append({"label": label, "from": start, "to": end, "fund": f,
262
+ "bench": _window_move(idx, start, end) if idx is not None else None})
263
+ return {"current": float(under.iloc[-1]), "max": float(under.min()), "episodes": _episodes(fund),
264
+ "crashes": crashes, "chart": chart}
265
+
266
+
267
+ def _bench_history(ticker: str) -> Optional[pd.Series]:
268
+ from ..portfolio.analytics import _benchmark_long
269
+
270
+ return _benchmark_long(ticker)
271
+
272
+
273
+ def dividend_yield(ticker: str, fetch: BenchFetch = _bench_history) -> Optional[float]:
274
+ """Yearly gap between an ETF's dividend-adjusted price and its price index
275
+ over the last YIELD_YEARS (or as long as both exist, after the ETF's first month)."""
276
+ etf_ticker = TR_PROXY.get(ticker)
277
+ if etf_ticker is None:
278
+ sister = TR_SISTER.get(ticker)
279
+ return dividend_yield(sister, fetch) if sister else None
280
+ index, etf = fetch(ticker), fetch(etf_ticker)
281
+ if index is None or etf is None or len(etf) < 260:
282
+ return None
283
+ pair = pd.concat([_daily(index), _daily(etf)], axis=1, keys=["i", "e"]).dropna()
284
+ pair = pair[pair.index >= max(pair.index[0] + pd.Timedelta(days=30),
285
+ pair.index[-1] - pd.DateOffset(years=YIELD_YEARS))]
286
+ if len(pair) < 365:
287
+ return None
288
+ years = (pair.index[-1] - pair.index[0]).days / 365.25
289
+ growth = (pair["e"].iloc[-1] / pair["e"].iloc[0]) / (pair["i"].iloc[-1] / pair["i"].iloc[0])
290
+ gap = growth ** (1 / years) - 1 # the rate total_return() compounds back in
291
+ return float(min(max(gap, 0.0), MAX_YIELD))
292
+
293
+
294
+ def total_return(index: pd.Series, yearly: float) -> pd.Series:
295
+ """Price index with dividends credited at a steady yearly rate."""
296
+ idx = index.sort_index()
297
+ t = (pd.DatetimeIndex(idx.index) - pd.DatetimeIndex(idx.index)[0]).days / 365.25
298
+ return idx * np.power(1 + yearly, t)
299
+
300
+
301
+ def _row(code: str) -> Optional[dict]:
302
+ from ..data.listings import get_store
303
+
304
+ return next((r for r in get_store().rows("india") if r.get("type") == "mf" and str(r["symbol"]) == code), None)
305
+
306
+
307
+ def is_payout(row: Optional[dict]) -> bool:
308
+ """IDCW / dividend options: each payout lowers the NAV, so NAV returns understate them."""
309
+ text = f"{(row or {}).get('option') or ''} {(row or {}).get('name') or ''}".lower()
310
+ return any(k in text for k in ("idcw", "dividend option", "payout", "income distribution"))
311
+
312
+
313
+ def build(code: str, fetch: Optional[NavFetch] = None, bench_fetch: BenchFetch = _bench_history,
314
+ row: Optional[dict] = None, use_cache: bool = True) -> dict:
315
+ code = str(code)
316
+ if use_cache:
317
+ with _lock:
318
+ hit = _cache.get(code)
319
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
320
+ return hit[1]
321
+ if fetch is None:
322
+ from .fund_peers import nav as fetch
323
+ row = row if row is not None else _row(code)
324
+ nav = fetch(code)
325
+ if nav is None or len(nav) < 60:
326
+ return {"status": "no_history", "note": "Not enough NAV history for this fund yet."}
327
+ nav = clean_nav(nav)
328
+ bench_info = benchmark_for((row or {}).get("sector"), (row or {}).get("name", ""))
329
+ bench, yearly = None, None
330
+ if bench_info:
331
+ try:
332
+ price = bench_fetch(bench_info[0])
333
+ yearly = dividend_yield(bench_info[0], bench_fetch) if price is not None else None
334
+ bench = total_return(price, yearly or 0.0) if price is not None else None
335
+ except Exception as exc:
336
+ logger.info("Benchmark %s unavailable: %s", bench_info[0], exc)
337
+ out = {
338
+ "status": "ok",
339
+ "code": code,
340
+ "category": (row or {}).get("sector"),
341
+ "nav_from": str(pd.Timestamp(nav.index.min()).date()),
342
+ "nav_to": str(pd.Timestamp(nav.index.max()).date()),
343
+ "benchmark": {"symbol": bench_info[0], "name": bench_info[1], "available": bench is not None,
344
+ "dividend_yield": yearly * 100 if yearly is not None else None}
345
+ if bench_info else None,
346
+ "rolling": rolling(nav, bench),
347
+ "payout_option": is_payout(row),
348
+ "risk": risk(nav, bench),
349
+ "drawdowns": drawdowns(nav, bench),
350
+ "note": "NAV history from official fund data; index history from the market data feed, with dividends added back at the "
351
+ "rate an ETF on the index earned over it, so the comparison is total return against total "
352
+ "return, as SEBI asks. Past returns don't predict future ones.",
353
+ }
354
+ _clean(out)
355
+ if use_cache:
356
+ with _lock:
357
+ _cache[code] = (time.time(), out)
358
+ return out
359
+
360
+
361
+ def _clean(obj):
362
+ """NaN / inf -> None everywhere (JSON has no NaN)."""
363
+ if isinstance(obj, dict):
364
+ for k, v in obj.items():
365
+ obj[k] = _clean(v)
366
+ elif isinstance(obj, list):
367
+ for i, v in enumerate(obj):
368
+ obj[i] = _clean(v)
369
+ elif isinstance(obj, float) and not math.isfinite(obj):
370
+ return None
371
+ return obj