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,247 @@
1
+ """A mutual fund against its competitors.
2
+
3
+ Competitors are the other schemes in the same SEBI category ("Flexi Cap
4
+ Fund", "Large Cap Fund"...), same plan (Direct / Regular) and growth option,
5
+ one per fund house - the funds an investor would really choose between.
6
+
7
+ Each is measured from its own NAV history (mfapi.in, cached 12 hours):
8
+ returns 1Y, 3Y and 5Y (3Y / 5Y annualised)
9
+ volatility annualised, last 3 years of daily NAVs
10
+ drawdown worst peak-to-trough fall, last 3 years
11
+ sharpe 3Y return over a 6.5% risk-free rate, per unit of volatility
12
+ similarity correlation of daily returns with the chosen fund (last year):
13
+ 0.95+ means the two move almost as one
14
+ overlap shared holdings, when both fund houses' portfolio files are
15
+ loaded (marketmyna/services/fund_holdings.py)
16
+ The chosen fund also gets its rank and quartile in the category on each.
17
+ Facts from public NAVs; past returns don't predict future ones.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import math
25
+ import re
26
+ import threading
27
+ import time
28
+ from concurrent.futures import ThreadPoolExecutor
29
+ from pathlib import Path
30
+ from typing import Callable, Optional
31
+
32
+ import pandas as pd
33
+
34
+ from marketmyna.core.home import app_home
35
+
36
+ from ..core.numbers import finite as _f
37
+ from .sip_days import mf_nav
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ NAV_TTL = 12 * 3600
42
+ RISK_FREE = 6.5 # % a year, Indian T-bill ballpark
43
+ MAX_PEERS = 60
44
+ TRADING_DAYS = 252
45
+ _EXCLUDE = re.compile(r"\b(idcw|dividend|bonus|payout|reinvest|segregated|series|fmp|fixed (term|maturity)|interval)\b", re.I)
46
+
47
+ NavFetch = Callable[[str], Optional[pd.Series]]
48
+ _mem: dict[str, tuple[float, Optional[pd.Series]]] = {}
49
+ _lock = threading.Lock()
50
+
51
+
52
+ # ---------------------------------------------------------------- NAV cache
53
+
54
+ def _cache_dir() -> Path:
55
+ p = app_home() / "cache" / "nav"
56
+ p.mkdir(parents=True, exist_ok=True)
57
+ return p
58
+
59
+
60
+ def nav(code: str, fetch: NavFetch = mf_nav) -> Optional[pd.Series]:
61
+ """Daily NAVs, oldest first; memory then disk cache, 12 hours."""
62
+ now = time.time()
63
+ with _lock:
64
+ hit = _mem.get(code)
65
+ if hit and now - hit[0] < NAV_TTL:
66
+ return hit[1]
67
+ path = _cache_dir() / f"{code}.json"
68
+ series: Optional[pd.Series] = None
69
+ try:
70
+ if path.exists() and now - path.stat().st_mtime < NAV_TTL:
71
+ raw = json.loads(path.read_text(encoding="utf-8"))
72
+ series = pd.Series(raw["nav"], index=pd.to_datetime(raw["date"]), dtype=float)
73
+ except Exception:
74
+ series = None
75
+ if series is None:
76
+ try:
77
+ series = fetch(code)
78
+ except Exception as exc:
79
+ logger.info("NAV history for %s unavailable: %s", code, exc)
80
+ series = None
81
+ if series is not None and len(series):
82
+ try:
83
+ path.write_text(json.dumps({"date": [d.strftime("%Y-%m-%d") for d in series.index],
84
+ "nav": [float(v) for v in series.values]}), encoding="utf-8")
85
+ except OSError:
86
+ pass
87
+ with _lock:
88
+ _mem[code] = (now, series)
89
+ return series
90
+
91
+
92
+ # ---------------------------------------------------------------- metrics
93
+
94
+ def _ret(s: pd.Series, years: float) -> Optional[float]:
95
+ if s is None or len(s) < 2:
96
+ return None
97
+ end = s.index[-1]
98
+ start_at = end - pd.DateOffset(days=round(365.25 * years))
99
+ if s.index[0] > start_at + pd.Timedelta(days=7):
100
+ return None # fund not that old
101
+ base = s[s.index <= start_at]
102
+ if base.empty or base.iloc[-1] <= 0:
103
+ return None
104
+ growth = s.iloc[-1] / base.iloc[-1]
105
+ return (growth ** (1 / years) - 1) * 100 if years > 1 else (growth - 1) * 100
106
+
107
+
108
+ def metrics(s: Optional[pd.Series]) -> dict:
109
+ out = {"r1": None, "r3": None, "r5": None, "volatility": None, "drawdown": None, "sharpe": None,
110
+ "since": None, "nav": None}
111
+ if s is None or len(s) < 30:
112
+ return out
113
+ out["nav"] = _f(s.iloc[-1])
114
+ out["since"] = s.index[0].strftime("%Y-%m-%d")
115
+ out["r1"], out["r3"], out["r5"] = _ret(s, 1), _ret(s, 3), _ret(s, 5)
116
+ last3 = s[s.index >= s.index[-1] - pd.DateOffset(years=3)]
117
+ daily = last3.pct_change().dropna()
118
+ if len(daily) > 60:
119
+ vol = float(daily.std() * math.sqrt(TRADING_DAYS) * 100)
120
+ out["volatility"] = vol
121
+ peak = last3.cummax()
122
+ out["drawdown"] = float(((last3 / peak) - 1).min() * 100)
123
+ basis = out["r3"] if out["r3"] is not None else out["r1"]
124
+ if basis is not None and vol > 0:
125
+ out["sharpe"] = (basis - RISK_FREE) / vol
126
+ return out
127
+
128
+
129
+ def similarity(a: Optional[pd.Series], b: Optional[pd.Series]) -> Optional[float]:
130
+ if a is None or b is None:
131
+ return None
132
+ cutoff = a.index[-1] - pd.DateOffset(years=1)
133
+ ra = a[a.index >= cutoff].pct_change()
134
+ rb = b[b.index >= cutoff].pct_change()
135
+ joined = pd.concat([ra, rb], axis=1, join="inner").dropna()
136
+ if len(joined) < 60:
137
+ return None
138
+ return _f(joined.iloc[:, 0].corr(joined.iloc[:, 1]))
139
+
140
+
141
+ # ---------------------------------------------------------------- peers
142
+
143
+ def _growth(row: dict) -> bool:
144
+ text = f"{row.get('option') or ''} {row.get('name') or ''}"
145
+ return "growth" in text.lower() and not _EXCLUDE.search(text)
146
+
147
+
148
+ def candidates(row: dict, rows: list[dict]) -> list[dict]:
149
+ """Same SEBI category and plan, growth option, open-ended; one per fund house
150
+ (the chosen fund always included)."""
151
+ category, plan = row.get("sector"), row.get("plan")
152
+ best: dict[str, dict] = {}
153
+ for r in rows:
154
+ if r.get("type") != "mf" or r.get("sector") != category or r.get("plan") != plan:
155
+ continue
156
+ if str(r["symbol"]) != str(row["symbol"]) and not _growth(r):
157
+ continue
158
+ if "close ended" in str(r.get("sector") or "").lower():
159
+ continue
160
+ amc = r.get("amc") or r["symbol"]
161
+ if str(r["symbol"]) == str(row["symbol"]):
162
+ best[amc] = r
163
+ elif amc not in best or (len(r["name"]) < len(best[amc]["name"]) and str(best[amc]["symbol"]) != str(row["symbol"])):
164
+ best[amc] = r # the plainest name is usually the flagship scheme
165
+ out = list(best.values())
166
+ if not any(str(r["symbol"]) == str(row["symbol"]) for r in out):
167
+ out.append(row)
168
+ return out
169
+
170
+
171
+ def _overlap(a: Optional[dict], b: Optional[dict]) -> Optional[float]:
172
+ """Share of the portfolio the two funds hold in common (sum of min weights)."""
173
+ from .fund_holdings import pair_overlap
174
+
175
+ out = pair_overlap(a, b)
176
+ return out["overlap"] if out else None
177
+
178
+
179
+ def _rank(values: list[tuple[str, Optional[float]]], code: str, higher_better: bool = True) -> Optional[dict]:
180
+ scored = [(c, v) for c, v in values if v is not None]
181
+ mine = next((v for c, v in scored if c == code), None)
182
+ if mine is None or len(scored) < 3:
183
+ return None
184
+ ordered = sorted(scored, key=lambda t: -t[1] if higher_better else t[1])
185
+ position = next(i for i, (c, _) in enumerate(ordered) if c == code) + 1
186
+ quartile = min(4, 1 + (position - 1) * 4 // len(ordered))
187
+ return {"rank": position, "of": len(ordered), "quartile": quartile,
188
+ "median": sorted(v for _, v in scored)[len(scored) // 2]}
189
+
190
+
191
+ def compare(code: str, rows: Optional[list[dict]] = None, fetch: NavFetch = mf_nav,
192
+ holdings: Optional[Callable[[str], dict]] = None) -> dict:
193
+ if rows is None:
194
+ from ..data.listings import get_store
195
+
196
+ rows = get_store().rows("india")
197
+ row = next((r for r in rows if r.get("type") == "mf" and str(r["symbol"]) == str(code)), None)
198
+ if row is None:
199
+ return {"status": "unknown", "note": "No such scheme."}
200
+ peers = candidates(row, rows)
201
+ if len(peers) > MAX_PEERS:
202
+ peers = sorted(peers, key=lambda r: str(r["symbol"]) != str(code))[:MAX_PEERS]
203
+ with ThreadPoolExecutor(max_workers=8) as pool:
204
+ series = dict(zip((str(p["symbol"]) for p in peers), pool.map(lambda p: nav(str(p["symbol"]), fetch), peers)))
205
+ mine = series.get(str(code))
206
+
207
+ if holdings is None:
208
+ from . import fund_holdings
209
+
210
+ holdings = fund_holdings.for_fund
211
+ my_holdings = None
212
+ try:
213
+ my_holdings = holdings(str(code))
214
+ except Exception:
215
+ pass
216
+
217
+ out_rows = []
218
+ for p in peers:
219
+ c = str(p["symbol"])
220
+ m = metrics(series.get(c))
221
+ if m["nav"] is None:
222
+ continue
223
+ overlap = None
224
+ if c != str(code) and my_holdings and my_holdings.get("status") == "ok":
225
+ try:
226
+ overlap = _overlap(my_holdings, holdings(c))
227
+ except Exception:
228
+ overlap = None
229
+ out_rows.append({"code": c, "name": p["name"], "amc": p.get("amc"), "is_self": c == str(code),
230
+ **m, "similarity": 1.0 if c == str(code) else similarity(mine, series.get(c)),
231
+ "overlap": overlap})
232
+ out_rows.sort(key=lambda r: (r["r3"] is None, -(r["r3"] or 0), -(r["r1"] or 0)))
233
+
234
+ ranks = {}
235
+ for key, higher in (("r1", True), ("r3", True), ("r5", True), ("volatility", False), ("drawdown", True),
236
+ ("sharpe", True)):
237
+ ranks[key] = _rank([(r["code"], r[key]) for r in out_rows], str(code), higher)
238
+ closest = sorted((r for r in out_rows if not r["is_self"] and r["similarity"] is not None),
239
+ key=lambda r: -r["similarity"])[:3]
240
+ return {
241
+ "status": "ok", "code": str(code), "name": row["name"],
242
+ "category": row.get("sector"), "group": row.get("category"), "plan": row.get("plan"),
243
+ "count": len(out_rows), "ranks": ranks, "rows": out_rows,
244
+ "closest": [{"code": r["code"], "name": r["name"], "similarity": r["similarity"]} for r in closest],
245
+ "risk_free": RISK_FREE,
246
+ "note": "Same SEBI category and plan, growth option, one scheme per fund house. Past returns don't predict future ones.",
247
+ }
@@ -0,0 +1,206 @@
1
+ """Relative rotation for Indian equity mutual funds: is a category, a fund
2
+ house, or a single fund Leading, Weakening, Lagging or Improving against the
3
+ Nifty 500? Same RS-Ratio / RS-Momentum maths as the stock sector view
4
+ (services/rotation.py), on weekly NAVs.
5
+
6
+ - Categories (Large Cap, Mid Cap, Small Cap, Flexi Cap, ELSS, ...) and fund
7
+ houses are equal-weighted baskets of their Direct-Growth equity funds;
8
+ the longest-running schemes (lowest AMFI codes) are picked first.
9
+ - Drill into one category or fund house to see each of its funds.
10
+ NAVs come from mfapi.in (AMFI data); each fund's history is cached for the
11
+ day, results for a few hours (NAVs change once a day).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ import time
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import Callable, Optional
21
+
22
+ import pandas as pd
23
+
24
+ from .rotation import TAIL, WINDOW, _weekly, quadrant, rrg
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ BENCHMARK = ("^CRSLDX", "Nifty 500")
29
+ BASKET = 8 # funds per category / fund house basket
30
+ DRILL = 15 # funds shown when drilling into one group
31
+ MIN_WEEKS = 30
32
+ CACHE_SECONDS = 6 * 3600
33
+ EQUITY_CATEGORIES = ("Equity", "ELSS (Tax Saving)")
34
+ # Fund-house baskets lean on the core diversified categories first.
35
+ CORE = ("Large Cap Fund", "Flexi Cap Fund", "Large & Mid Cap Fund", "Mid Cap Fund", "Small Cap Fund",
36
+ "Multi Cap Fund", "ELSS", "Focused Fund", "Value Fund")
37
+
38
+ NavFetch = Callable[[int], Optional[pd.Series]]
39
+ _cache: dict[tuple, tuple[float, dict]] = {}
40
+ _navs: dict[int, tuple[float, Optional[pd.Series]]] = {}
41
+ _lock = threading.Lock()
42
+
43
+
44
+ def _nav(code: int) -> Optional[pd.Series]:
45
+ from ..data.mutual_funds import MutualFundDataFetcher
46
+
47
+ with _lock:
48
+ hit = _navs.get(code)
49
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
50
+ return hit[1]
51
+ try:
52
+ df = MutualFundDataFetcher().get_historical_nav(code, days=400)
53
+ s = df["nav"].astype(float) if not df.empty else None
54
+ except Exception as exc:
55
+ logger.debug("NAV %s failed: %s", code, exc)
56
+ s = None
57
+ with _lock:
58
+ _navs[code] = (time.time(), s)
59
+ return s
60
+
61
+
62
+ def _bench() -> pd.Series:
63
+ import yfinance as yf
64
+
65
+ h = yf.Ticker(BENCHMARK[0]).history(period="1y")
66
+ if h.empty:
67
+ raise ValueError(f"{BENCHMARK[1]} is unavailable right now")
68
+ return _weekly(h["Close"])
69
+
70
+
71
+ def equity_funds(rows: list[dict]) -> list[dict]:
72
+ """Direct-plan, growth-option equity funds (not index funds / ETFs)."""
73
+ out = []
74
+ for r in rows:
75
+ if r.get("type") != "mf" or r.get("category") not in EQUITY_CATEGORIES:
76
+ continue
77
+ option = (r.get("option") or "").lower()
78
+ if r.get("plan") != "Direct" or "growth" not in option or "idcw" in option:
79
+ continue
80
+ if not str(r.get("symbol", "")).isdigit():
81
+ continue
82
+ out.append(r)
83
+ # Oldest schemes first; one entry per fund name (a merged scheme can keep
84
+ # two AMFI codes under the same name).
85
+ seen, unique = set(), []
86
+ for r in sorted(out, key=lambda r: int(r["symbol"])):
87
+ k = (r.get("amc"), _short(r.get("name", "")).lower())
88
+ if k not in seen:
89
+ seen.add(k)
90
+ unique.append(r)
91
+ return unique
92
+
93
+
94
+ def _group_key(r: dict, by: str) -> Optional[str]:
95
+ if by == "amc":
96
+ return r.get("amc")
97
+ sector = r.get("sector") or ""
98
+ if "ELSS" in sector or r.get("category") == "ELSS (Tax Saving)":
99
+ return "ELSS"
100
+ if sector in ("Sectoral/ Thematic", "Thematic Fund", "Sectoral Fund"):
101
+ return "Sectoral / Thematic"
102
+ return sector or None
103
+
104
+
105
+ def _short(name: str) -> str:
106
+ for cut in (" - Direct Plan", " Direct Plan", " - Direct", "-Direct", " Direct"):
107
+ if cut in name:
108
+ return name.split(cut)[0].strip()
109
+ return name
110
+
111
+
112
+ def _point(weekly: pd.Series, bench: pd.Series) -> Optional[dict]:
113
+ path = rrg(weekly, bench)
114
+ if path.empty:
115
+ return None
116
+ tail = path.tail(TAIL)
117
+ last = tail.iloc[-1]
118
+ return {
119
+ "ratio": float(last["ratio"]), "momentum": float(last["momentum"]),
120
+ "quadrant": quadrant(float(last["ratio"]), float(last["momentum"])),
121
+ "return_4w": float(weekly.iloc[-1] / weekly.iloc[-5] - 1) * 100 if len(weekly) > 5 else None,
122
+ "tail": [{"week": str(i.date()), "ratio": float(v["ratio"]), "momentum": float(v["momentum"])}
123
+ for i, v in tail.iterrows()],
124
+ }
125
+
126
+
127
+ def build(by: str = "category", name: Optional[str] = None, rows: Optional[list[dict]] = None,
128
+ nav: NavFetch = _nav, bench: Optional[Callable[[], pd.Series]] = None) -> dict:
129
+ """by: "category" or "amc". With `name`, each fund inside that group."""
130
+ if by not in ("category", "amc"):
131
+ raise ValueError("Group by category or amc")
132
+ key = (by, name)
133
+ use_cache = rows is None
134
+ if use_cache:
135
+ with _lock:
136
+ hit = _cache.get(key)
137
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
138
+ return hit[1]
139
+ from ..data.listings import get_store
140
+
141
+ rows = get_store().rows("india")
142
+
143
+ funds = equity_funds(rows)
144
+ groups: dict[str, list[dict]] = {}
145
+ for r in funds:
146
+ g = _group_key(r, by)
147
+ if g:
148
+ groups.setdefault(g, []).append(r)
149
+
150
+ if name is not None:
151
+ if name not in groups:
152
+ raise ValueError(f"No equity funds found for '{name}'")
153
+ picked = {name: groups[name][:DRILL]}
154
+ elif by == "amc":
155
+ # A fund house: its core diversified funds first, then the rest.
156
+ picked = {g: sorted(m, key=lambda r: (_group_key(r, "category") not in CORE, int(r["symbol"])))[:BASKET]
157
+ for g, m in groups.items() if len(m) >= 3}
158
+ else:
159
+ picked = {g: m[:BASKET] for g, m in groups.items() if len(m) >= 3}
160
+
161
+ codes = sorted({int(r["symbol"]) for m in picked.values() for r in m})
162
+ with ThreadPoolExecutor(max_workers=8) as pool:
163
+ series = dict(zip(codes, pool.map(nav, codes)))
164
+ weekly = {c: _weekly(s) for c, s in series.items() if s is not None and len(s.dropna()) > MIN_WEEKS * 5}
165
+ b = (bench or _bench)()
166
+
167
+ items = []
168
+ for g, members in picked.items():
169
+ if name is not None: # one point per fund
170
+ for r in members:
171
+ w = weekly.get(int(r["symbol"]))
172
+ if w is None or len(w) < MIN_WEEKS:
173
+ continue
174
+ p = _point(w, b)
175
+ if p:
176
+ items.append({"name": _short(r["name"]), "code": r["symbol"], "amc": r.get("amc"),
177
+ "category": _group_key(r, "category"), **p})
178
+ continue
179
+ parts = [weekly[int(r["symbol"])] for r in members if int(r["symbol"]) in weekly
180
+ and len(weekly[int(r["symbol"])]) >= MIN_WEEKS]
181
+ if len(parts) < 2:
182
+ continue
183
+ frame = pd.concat([p / p.iloc[0] for p in parts], axis=1).ffill().dropna()
184
+ basket = frame.mean(axis=1) # equal weight
185
+ p = _point(basket, b)
186
+ if p:
187
+ items.append({"name": g, "funds": len(parts), "total_funds": len(groups[g]), **p})
188
+
189
+ order = {"leading": 0, "improving": 1, "weakening": 2, "lagging": 3}
190
+ items.sort(key=lambda s: (order[s["quadrant"]], -s["ratio"]))
191
+ out = {"by": by, "group": name, "benchmark": {"symbol": BENCHMARK[0], "name": BENCHMARK[1]},
192
+ "as_of": str(b.index[-1].date()), "window_weeks": WINDOW, "items": items,
193
+ "groups": sorted(groups) if name is None else None}
194
+ if use_cache:
195
+ with _lock:
196
+ _cache[key] = (time.time(), out)
197
+ return out
198
+
199
+
200
+ def warm() -> None:
201
+ """Pre-build both views after start-up so the first visitor doesn't wait."""
202
+ for by in ("category", "amc"):
203
+ try:
204
+ build(by)
205
+ except Exception as exc:
206
+ logger.info("Fund rotation warm-up (%s) skipped: %s", by, exc)
@@ -0,0 +1,179 @@
1
+ """A fund's investing style from what it actually holds, and whether it
2
+ stays inside its SEBI category's rules.
3
+
4
+ Style: the fund's P/E and P/B (weight-averaged the harmonic way, which is
5
+ how an index's P/E is built) against the same measures for the 100 largest
6
+ Indian companies. Cheaper on both = Value, dearer = Growth, else Blend.
7
+ Size: from the large / mid / small split of its Indian shares.
8
+
9
+ Drift: SEBI's categorisation rules (2017, multi cap amended 2020) as a share
10
+ of the whole fund - Large Cap >= 80% large caps, Mid Cap >= 65% mid, Small
11
+ Cap >= 65% small, Large & Mid >= 35% each, Multi Cap >= 25% each of large,
12
+ mid and small, Focused <= 30 stocks, equity categories >= 65% (ELSS 80%) in
13
+ shares. Company sizes here use today's market caps; AMFI's official list uses
14
+ six-month averages, so a fund right at a limit may differ slightly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ import threading
22
+ import time
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from typing import Callable, Optional
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ CACHE_SECONDS = 12 * 3600
29
+ MARKET_N = 100 # "the market" for style = the 100 largest companies (like the Nifty 100)
30
+ VALUE_GROWTH_BAND = 0.15 # average log ratio beyond +-0.15 (~16%) leaves Blend
31
+
32
+ # category keyword -> list of (rule label, check(split, equity_pct, count) -> bool)
33
+ RULES: list[tuple[str, list[tuple[str, Callable[[dict, float, int], bool]]]]] = [
34
+ ("large & mid", [("At least 35% in large caps", lambda s, e, n: s["large"] >= 35),
35
+ ("At least 35% in mid caps", lambda s, e, n: s["mid"] >= 35)]),
36
+ ("large cap", [("At least 80% in large caps", lambda s, e, n: s["large"] >= 80)]),
37
+ ("mid cap", [("At least 65% in mid caps", lambda s, e, n: s["mid"] >= 65)]),
38
+ ("small cap", [("At least 65% in small caps", lambda s, e, n: s["small"] >= 65)]),
39
+ ("multi cap", [("At least 75% in shares", lambda s, e, n: e >= 75),
40
+ ("At least 25% each in large, mid and small caps",
41
+ lambda s, e, n: min(s["large"], s["mid"], s["small"]) >= 25)]),
42
+ ("focused", [("No more than 30 stocks", lambda s, e, n: n <= 30),
43
+ ("At least 65% in shares", lambda s, e, n: e >= 65)]),
44
+ ("elss", [("At least 80% in shares", lambda s, e, n: e >= 80)]),
45
+ ("flexi cap", [("At least 65% in shares", lambda s, e, n: e >= 65)]),
46
+ ("value", [("At least 65% in shares", lambda s, e, n: e >= 65)]),
47
+ ("contra", [("At least 65% in shares", lambda s, e, n: e >= 65)]),
48
+ ("dividend yield", [("At least 65% in shares", lambda s, e, n: e >= 65)]),
49
+ ]
50
+
51
+ _cache: dict[str, tuple[float, dict]] = {}
52
+ _market: dict[str, tuple[float, dict]] = {}
53
+ _lock = threading.Lock()
54
+
55
+ InfoFetch = Callable[[str], dict]
56
+
57
+
58
+ def _info(symbol: str) -> dict:
59
+ from .analysis_run import yahoo_symbol
60
+ from .compare import _yahoo_info
61
+
62
+ return _yahoo_info(yahoo_symbol(symbol, "india")) or {}
63
+
64
+
65
+ def _pos(v) -> Optional[float]:
66
+ try:
67
+ f = float(v)
68
+ except (TypeError, ValueError):
69
+ return None
70
+ return f if math.isfinite(f) and f > 0 else None
71
+
72
+
73
+ def weighted_ratios(items: list[tuple[float, dict]]) -> dict:
74
+ """items: (weight, yahoo info). Harmonic weight-average for P/E and P/B (a
75
+ loss-maker's P/E is skipped), plain weight-average for ROE; with coverage."""
76
+ out: dict = {}
77
+ total = sum(w for w, _ in items) or 1.0
78
+ for key, field in (("pe", "trailingPE"), ("pb", "priceToBook")):
79
+ pairs = [(w, _pos(i.get(field))) for w, i in items]
80
+ pairs = [(w, v) for w, v in pairs if v]
81
+ cover = sum(w for w, _ in pairs)
82
+ out[key] = cover / sum(w / v for w, v in pairs) if pairs else None
83
+ out[f"{key}_coverage"] = cover / total * 100
84
+ roe = [(w, i.get("returnOnEquity")) for w, i in items if isinstance(i.get("returnOnEquity"), (int, float))]
85
+ cw = sum(w for w, _ in roe)
86
+ out["roe"] = sum(w * v for w, v in roe) / cw * 100 if cw else None
87
+ return out
88
+
89
+
90
+ def market_ratios(fetch: InfoFetch = _info, rows: Optional[list[dict]] = None) -> dict:
91
+ """Cap-weighted P/E and P/B of the 100 largest Indian companies, cached."""
92
+ with _lock:
93
+ hit = _market.get("india")
94
+ if rows is None and hit and time.time() - hit[0] < CACHE_SECONDS:
95
+ return hit[1]
96
+ if rows is None:
97
+ from ..data.listings import get_store
98
+
99
+ rows = get_store().rows("india")
100
+ top = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
101
+ key=lambda r: -r["market_cap"])[:MARKET_N]
102
+ with ThreadPoolExecutor(max_workers=8) as pool:
103
+ infos = list(pool.map(lambda r: fetch(r["symbol"]), top))
104
+ out = weighted_ratios([(r["market_cap"], i or {}) for r, i in zip(top, infos)])
105
+ with _lock:
106
+ _market["india"] = (time.time(), out)
107
+ return out
108
+
109
+
110
+ def style_label(fund: dict, market: dict) -> Optional[dict]:
111
+ logs = [math.log(fund[k] / market[k]) for k in ("pe", "pb") if fund.get(k) and market.get(k)]
112
+ if not logs:
113
+ return None
114
+ score = sum(logs) / len(logs)
115
+ label = "Value" if score < -VALUE_GROWTH_BAND else "Growth" if score > VALUE_GROWTH_BAND else "Blend"
116
+ return {"label": label, "score": score}
117
+
118
+
119
+ def size_label(split: dict) -> Optional[str]:
120
+ domestic = split["large"] + split["mid"] + split["small"]
121
+ if domestic <= 0:
122
+ return None
123
+ large, mid, small = (split[k] / domestic * 100 for k in ("large", "mid", "small"))
124
+ if large >= 70:
125
+ return "Large"
126
+ if small >= 35:
127
+ return "Small"
128
+ if mid >= 35:
129
+ return "Mid"
130
+ return "Multi"
131
+
132
+
133
+ def rule_checks(category: Optional[str], split: dict, equity_pct: float, count: int) -> list[dict]:
134
+ cat = (category or "").lower()
135
+ for key, rules in RULES:
136
+ if key in cat:
137
+ return [{"rule": label, "ok": bool(check(split, equity_pct, count))} for label, check in rules]
138
+ return []
139
+
140
+
141
+ def build(code: str, holdings: Optional[dict] = None, fetch: InfoFetch = _info,
142
+ market: Optional[dict] = None, category: Optional[str] = None, use_cache: bool = True) -> dict:
143
+ code = str(code)
144
+ if use_cache and holdings is None:
145
+ with _lock:
146
+ hit = _cache.get(code)
147
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
148
+ return hit[1]
149
+ if holdings is None:
150
+ from . import fund_holdings
151
+
152
+ holdings = fund_holdings.for_fund(code)
153
+ if holdings.get("status") != "ok":
154
+ return {"status": "missing", "note": holdings.get("note") or "Holdings are not available for this fund yet."}
155
+ if category is None:
156
+ from ..data.listings import get_store
157
+
158
+ row = next((r for r in get_store().rows("india") if r.get("type") == "mf" and str(r["symbol"]) == code), {})
159
+ category = row.get("sector")
160
+ split = {"large": 0.0, "mid": 0.0, "small": 0.0, "foreign": 0.0, "other": 0.0}
161
+ for c in holdings.get("cap_split") or []:
162
+ split[c["kind"]] = c["weight"]
163
+ stocks = [h for h in holdings.get("holdings", []) if h.get("kind") in ("equity", "foreign_equity") and h.get("weight")]
164
+ equity_pct = sum(h["weight"] for h in stocks)
165
+ indian = [h for h in stocks if h.get("symbol")]
166
+ with ThreadPoolExecutor(max_workers=8) as pool:
167
+ infos = list(pool.map(lambda h: fetch(h["symbol"]), indian))
168
+ ratios = weighted_ratios([(h["weight"], i or {}) for h, i in zip(indian, infos)])
169
+ mkt = market if market is not None else market_ratios(fetch)
170
+ out = {"status": "ok", "category": category, "size": size_label(split), "style": style_label(ratios, mkt),
171
+ "fund": ratios, "market": mkt, "equity_pct": equity_pct, "split": split, "count": len(stocks),
172
+ "checks": rule_checks(category, split, equity_pct, len(stocks)), "as_of": holdings.get("as_of"),
173
+ "note": "Valuations from the market data feed for the fund's Indian holdings; 'market' = the 100 largest "
174
+ "Indian companies. Company sizes use today's market caps (AMFI's official list uses "
175
+ "six-month averages)."}
176
+ if use_cache:
177
+ with _lock:
178
+ _cache[code] = (time.time(), out)
179
+ return out