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,664 @@
1
+ """What a mutual fund holds, from the official monthly portfolio files.
2
+
3
+ SEBI makes every fund house publish each scheme's full portfolio within ten
4
+ days of month end, as an Excel workbook with one sheet per scheme in a common
5
+ layout: a "Name of the Instrument | ISIN | Industry / Rating | Quantity |
6
+ Market Value | % to Net Assets" table split into sections (equity, arbitrage,
7
+ REITs, foreign equity, money market, TREPS ...) ending at GRAND TOTAL, with
8
+ derivatives listed after it.
9
+
10
+ Files reach the app two ways:
11
+ - auto-download for fund houses whose site lists the files at a stable page
12
+ (AUTO_SOURCES), refreshed in the background;
13
+ - a file placed by hand with ingest() (no upload screen in this build).
14
+ The last two months are kept per fund house, so a fund's page can show what
15
+ it bought and sold. Index funds without a file fall back to the NSE index
16
+ list (marketmyna/services/etf.py), with estimated weights.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import difflib
22
+ import io
23
+ import json
24
+ import logging
25
+ import re
26
+ import threading
27
+ import time
28
+ import zipfile
29
+ from datetime import datetime
30
+ from functools import lru_cache
31
+ from pathlib import Path
32
+ from typing import Callable, Optional
33
+
34
+ import pandas as pd
35
+
36
+ from marketmyna.core.home import app_home
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
41
+ KEEP_MONTHS = 2
42
+ MAX_UPLOAD = 25 * 1024 * 1024
43
+ REFRESH_SECONDS = 24 * 3600
44
+ TOP_N = 15
45
+
46
+ # Section headings -> holding kind. Checked in order; first match wins.
47
+ SECTIONS = (
48
+ (r"arbitrage", "arbitrage"),
49
+ (r"foreign|overseas|international", "foreign_equity"),
50
+ (r"reit|invit", "reit"),
51
+ (r"mutual fund|units of|etf", "fund"),
52
+ (r"repo|treps|tri-party|cblo", "cash"),
53
+ (r"money market|certificate of deposit|commercial paper|treasury|t-bill|debt|bond|debenture|"
54
+ r"government|g-sec|state development|sdl|securiti|ptc|zero coupon", "debt"),
55
+ (r"equity|shares", "equity"),
56
+ )
57
+ STOP = re.compile(r"grand total|^derivatives|^total net assets", re.I)
58
+
59
+ _lock = threading.Lock()
60
+ _index_cache: dict[str, tuple[float, dict]] = {}
61
+
62
+
63
+ def _root() -> Path:
64
+ p = app_home() / "mf_portfolios"
65
+ p.mkdir(parents=True, exist_ok=True)
66
+ return p
67
+
68
+
69
+ def slug(amc: str) -> str:
70
+ return re.sub(r"[^a-z0-9]+", "-", (amc or "").lower()).strip("-")[:60] or "unknown"
71
+
72
+
73
+ # ---------------------------------------------------------------- parsing
74
+
75
+ def _num(v) -> Optional[float]:
76
+ if v is None or (isinstance(v, float) and pd.isna(v)):
77
+ return None
78
+ if isinstance(v, (int, float)):
79
+ return float(v)
80
+ s = str(v).strip().replace(",", "").replace("$", "").replace("%", "").replace("^", "").strip()
81
+ if s.startswith("(") and s.endswith(")"):
82
+ s = "-" + s[1:-1]
83
+ try:
84
+ return float(s)
85
+ except ValueError:
86
+ return None
87
+
88
+
89
+ def _text(v) -> str:
90
+ if v is None or (isinstance(v, float) and pd.isna(v)):
91
+ return ""
92
+ return re.sub(r"\s+", " ", str(v)).strip()
93
+
94
+
95
+ def _header(df: pd.DataFrame) -> Optional[tuple[int, dict[str, int]]]:
96
+ for i in range(min(len(df), 40)):
97
+ cells = [_text(v).lower() for v in df.iloc[i].tolist()]
98
+ if not any(c == "isin" or c.startswith("isin") for c in cells):
99
+ continue
100
+ cols: dict[str, int] = {}
101
+ for j, c in enumerate(cells):
102
+ if "isin" in c and "isin" not in cols:
103
+ cols["isin"] = j
104
+ elif ("instrument" in c or "name of" in c or c in ("security", "name", "company")) and "name" not in cols:
105
+ cols["name"] = j
106
+ elif ("industry" in c or "rating" in c or "sector" in c) and "industry" not in cols:
107
+ cols["industry"] = j
108
+ elif "quantity" in c and "qty" not in cols:
109
+ cols["qty"] = j
110
+ elif ("market" in c or "fair value" in c or "value" in c) and "value" not in cols and "%" not in c:
111
+ cols["value"] = j
112
+ elif ("% to" in c or "% of" in c or "net asset" in c or c.startswith("%")) and "pct" not in cols:
113
+ cols["pct"] = j
114
+ if "isin" in cols and "pct" in cols:
115
+ cols.setdefault("name", max(cols["isin"] - 1, 0))
116
+ return i, cols
117
+ return None
118
+
119
+
120
+ def _scheme_title(df: pd.DataFrame, header_row: int) -> tuple[str, Optional[str]]:
121
+ title, as_of = "", None
122
+ for i in range(header_row):
123
+ for v in df.iloc[i].tolist():
124
+ t = _text(v)
125
+ if not t:
126
+ continue
127
+ m = re.search(r"(\d{1,2})(?:st|nd|rd|th)?[\s\-/.,]*([A-Za-z]{3,9})[\s\-/.,]*(\d{4})", t) or \
128
+ re.search(r"([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})", t)
129
+ if m and as_of is None and re.search(r"as on|as at|portfolio|statement", t, re.I):
130
+ parts = m.groups()
131
+ day, mon, yr = (parts[1], parts[0], parts[2]) if parts[0].isalpha() else parts
132
+ for fmt in ("%d %B %Y", "%d %b %Y"):
133
+ try:
134
+ as_of = datetime.strptime(f"{int(day)} {mon} {yr}", fmt).date().isoformat()
135
+ break
136
+ except ValueError:
137
+ continue
138
+ if not title and not re.search(r"portfolio statement|monthly portfolio|as on|as at|^scheme name\s*:?$", t, re.I) \
139
+ and re.search(r"fund|scheme|plan|etf", t, re.I):
140
+ title = re.sub(r"^scheme name\s*:?\s*", "", t, flags=re.I)
141
+ title = re.sub(r"\s*\((?:an |a )?open[- ]ended.*$", "", title, flags=re.I)
142
+ title = re.sub(r"\s*\(.*?\)\s*$", "", title).strip(" -:")
143
+ return title, as_of
144
+
145
+
146
+ def parse_sheet(df: pd.DataFrame, sheet: str = "") -> Optional[dict]:
147
+ """One scheme's holdings from a SEBI-format sheet, or None if it isn't one."""
148
+ found = _header(df)
149
+ if not found:
150
+ return None
151
+ hrow, cols = found
152
+ title, as_of = _scheme_title(df, hrow)
153
+ kind = "equity"
154
+ raw, grand = [], None
155
+ for i in range(hrow + 1, len(df)):
156
+ row = df.iloc[i].tolist()
157
+ get = lambda k: row[cols[k]] if k in cols and cols[k] < len(row) else None # noqa: E731
158
+ name, isin = _text(get("name")), _text(get("isin")).upper()
159
+ label = name or " ".join(_text(v) for v in row if _text(v))
160
+ if STOP.search(label):
161
+ if re.search(r"grand total", label, re.I):
162
+ grand = _num(get("pct"))
163
+ break
164
+ if not ISIN_RE.match(isin):
165
+ if label and not re.match(r"^(sub ?total|total)\b", label, re.I):
166
+ low = label.lower()
167
+ for pattern, k in SECTIONS:
168
+ if re.search(pattern, low):
169
+ kind = k
170
+ break
171
+ continue
172
+ pct = _num(get("pct"))
173
+ if pct is None:
174
+ continue
175
+ raw.append({"name": name, "isin": isin, "industry": _text(get("industry")) or None,
176
+ "qty": _num(get("qty")), "value_lakh": _num(get("value")), "pct": pct, "kind": kind})
177
+ if not raw:
178
+ return None
179
+ # Weights come as fractions (0.0763) or percent (7.63) depending on the house.
180
+ total = sum(h["pct"] for h in raw)
181
+ fraction = (grand is not None and grand <= 1.5) or (grand is None and total <= 1.5)
182
+ holdings = []
183
+ for h in raw:
184
+ w = h.pop("pct")
185
+ h["weight"] = w * 100 if fraction else w
186
+ if h["kind"] == "equity" and not h["isin"].startswith("IN"):
187
+ h["kind"] = "foreign_equity"
188
+ holdings.append(h)
189
+ return {"sheet": sheet, "scheme": title or sheet, "as_of": as_of, "holdings": holdings}
190
+
191
+
192
+ def _frames(data: bytes, filename: str = "") -> list[tuple[str, pd.DataFrame]]:
193
+ if data[:2] == b"PK" and _is_plain_zip(data):
194
+ out = []
195
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
196
+ for n in zf.namelist():
197
+ if re.search(r"\.(xlsx?|xlsm)$", n, re.I):
198
+ out += _frames(zf.read(n), n)
199
+ return out
200
+ if data[:2] == b"PK":
201
+ book = pd.read_excel(io.BytesIO(data), sheet_name=None, header=None, engine="openpyxl")
202
+ elif data[:4] == b"\xd0\xcf\x11\xe0":
203
+ book = pd.read_excel(io.BytesIO(data), sheet_name=None, header=None, engine="xlrd")
204
+ elif data.lstrip()[:1] == b"<": # some houses serve HTML tables named .xls
205
+ book = {f"Table {i + 1}": t for i, t in enumerate(pd.read_html(io.BytesIO(data), header=None))}
206
+ else:
207
+ raise ValueError("Not an Excel file")
208
+ return list(book.items())
209
+
210
+
211
+ def _is_plain_zip(data: bytes) -> bool:
212
+ """A zip of workbooks (not an .xlsx, which is itself a zip)."""
213
+ try:
214
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
215
+ names = zf.namelist()
216
+ return "[Content_Types].xml" not in names and any(re.search(r"\.xlsx?$", n, re.I) for n in names)
217
+ except zipfile.BadZipFile:
218
+ return False
219
+
220
+
221
+ def parse_workbook(data: bytes, filename: str = "") -> list[dict]:
222
+ schemes = []
223
+ for sheet, df in _frames(data, filename):
224
+ try:
225
+ s = parse_sheet(df, str(sheet))
226
+ except Exception as exc:
227
+ logger.debug("Sheet %s skipped: %s", sheet, exc)
228
+ continue
229
+ if s:
230
+ schemes.append(s)
231
+ if not schemes:
232
+ raise ValueError("No scheme portfolios found. Expected the SEBI monthly portfolio layout "
233
+ "(Name of the Instrument, ISIN, ..., % to Net Assets).")
234
+ return schemes
235
+
236
+
237
+ # ---------------------------------------------------------------- storage
238
+
239
+ def _month(schemes: list[dict]) -> str:
240
+ dates = sorted({s["as_of"] for s in schemes if s.get("as_of")})
241
+ return (dates[-1] if dates else datetime.now().date().isoformat())[:7]
242
+
243
+
244
+ def store(amc: str, schemes: list[dict], source: str) -> dict:
245
+ """Save one fund house's month; keeps the newest KEEP_MONTHS."""
246
+ folder = _root() / slug(amc)
247
+ folder.mkdir(parents=True, exist_ok=True)
248
+ month = _month(schemes)
249
+ doc = {"amc": amc, "month": month, "source": source, "saved_at": time.time(), "schemes": schemes}
250
+ with _lock:
251
+ (folder / f"{month}.json").write_text(json.dumps(doc), encoding="utf-8")
252
+ files = sorted(folder.glob("*.json"))
253
+ for old in files[:-KEEP_MONTHS]:
254
+ old.unlink()
255
+ _load.cache_clear()
256
+ return {"amc": amc, "month": month, "schemes": len(schemes)}
257
+
258
+
259
+ def ingest(amc: str, data: bytes, filename: str = "", source: str = "upload") -> dict:
260
+ if len(data) > MAX_UPLOAD:
261
+ raise ValueError("File is larger than 25 MB")
262
+ return store(amc, parse_workbook(data, filename), source)
263
+
264
+
265
+ def ingest_many(amc: str, files: list[tuple[bytes, str]], source: str) -> dict:
266
+ """Several workbooks for the same month (houses that publish one file per scheme)."""
267
+ schemes = []
268
+ for data, name in files:
269
+ try:
270
+ schemes += parse_workbook(data, name)
271
+ except Exception as exc:
272
+ logger.info("%s: %s skipped: %s", amc, name, exc)
273
+ if not schemes:
274
+ raise ValueError(f"No portfolios parsed for {amc}")
275
+ return store(amc, schemes, source)
276
+
277
+
278
+
279
+ @lru_cache(maxsize=256)
280
+ def _load(amc_slug: str) -> tuple[dict, ...]:
281
+ folder = _root() / amc_slug
282
+ if not folder.exists():
283
+ return ()
284
+ docs = []
285
+ for f in sorted(folder.glob("*.json"), reverse=True):
286
+ try:
287
+ docs.append(json.loads(f.read_text(encoding="utf-8")))
288
+ except Exception:
289
+ continue
290
+ return tuple(docs) # newest first
291
+
292
+
293
+ def status() -> list[dict]:
294
+ out = []
295
+ for folder in sorted(p for p in _root().iterdir() if p.is_dir()):
296
+ docs = _load(folder.name)
297
+ if docs:
298
+ d = docs[0]
299
+ out.append({"amc": d["amc"], "month": d["month"], "schemes": len(d["schemes"]), "source": d.get("source"),
300
+ "saved_at": d.get("saved_at"), "months": [x["month"] for x in docs],
301
+ "auto": d["amc"] in AUTO_SOURCES})
302
+ return out
303
+
304
+
305
+ def delete(amc: str) -> None:
306
+ folder = _root() / slug(amc)
307
+ with _lock:
308
+ for f in folder.glob("*.json"):
309
+ f.unlink()
310
+ _load.cache_clear()
311
+
312
+
313
+ # ---------------------------------------------------------------- matching
314
+
315
+ _NOISE = re.compile(r"\b(direct|regular|plan|option|growth|idcw|dividend|payout|reinvest(?:ment)?|bonus|"
316
+ r"the|an|open ended|scheme|fund|mutual)\b")
317
+
318
+
319
+ def norm(name: str) -> str:
320
+ n = (name or "").lower().replace("&", " and ")
321
+ n = re.split(r"\s+-\s+(?:direct|regular)|\(", n)[0]
322
+ n = re.sub(r"[^a-z0-9 ]+", " ", n)
323
+ n = _NOISE.sub(" ", n)
324
+ return re.sub(r"\s+", " ", n).strip()
325
+
326
+
327
+ def match_scheme(fund_name: str, schemes: list[dict]) -> Optional[dict]:
328
+ target = norm(fund_name)
329
+ if not target:
330
+ return None
331
+ best, score = None, 0.0
332
+ for s in schemes:
333
+ cand = norm(s["scheme"])
334
+ if not cand:
335
+ continue
336
+ r = 1.0 if cand == target else difflib.SequenceMatcher(None, cand, target).ratio()
337
+ if r > score:
338
+ best, score = s, r
339
+ return best if score >= 0.86 else None
340
+
341
+
342
+ # ---------------------------------------------------------------- views
343
+
344
+ def _isin_symbols() -> dict[str, dict]:
345
+ from ..data.listings import get_store
346
+
347
+ return {r["isin"]: r for r in get_store().rows("india") if r.get("type") == "stock" and r.get("isin")}
348
+
349
+
350
+ def cap_split(stocks: list[dict], symbols: dict[str, dict]) -> Optional[list[dict]]:
351
+ """Large / mid / small cap share of the equity, SEBI's way: the 100
352
+ largest listed companies by market cap are large, the next 150 mid, the
353
+ rest small. Foreign shares and unlisted names are counted on their own."""
354
+ caps = sorted((r["market_cap"] for r in symbols.values() if r.get("market_cap")), reverse=True)
355
+ if len(caps) < 250 or not stocks:
356
+ return None
357
+ large_min, mid_min = caps[99], caps[249]
358
+ split = {"large": 0.0, "mid": 0.0, "small": 0.0, "foreign": 0.0, "other": 0.0}
359
+ for h in stocks:
360
+ row = symbols.get(h.get("isin") or "")
361
+ cap = (row or {}).get("market_cap")
362
+ if h["kind"] == "foreign_equity":
363
+ split["foreign"] += h["weight"]
364
+ elif not cap:
365
+ split["other"] += h["weight"]
366
+ else:
367
+ split["large" if cap >= large_min else "mid" if cap >= mid_min else "small"] += h["weight"]
368
+ return [{"kind": k, "weight": v} for k, v in split.items() if v > 0.05]
369
+
370
+
371
+ def _summarise(scheme: dict, prev: Optional[dict], symbols: dict[str, dict]) -> dict:
372
+ merged: dict[str, dict] = {}
373
+ for h in scheme["holdings"]:
374
+ key = f'{h["isin"]}:{"equity" if h["kind"] in ("equity", "arbitrage") else h["kind"]}'
375
+ m = merged.get(key)
376
+ if m:
377
+ m["weight"] += h["weight"]
378
+ m["value_lakh"] = (m.get("value_lakh") or 0) + (h.get("value_lakh") or 0)
379
+ if h["kind"] == "arbitrage":
380
+ m["hedged"] = (m.get("hedged") or 0) + h["weight"]
381
+ continue
382
+ row = symbols.get(h["isin"])
383
+ merged[key] = {**h, "kind": "equity" if h["kind"] == "arbitrage" else h["kind"],
384
+ "hedged": h["weight"] if h["kind"] == "arbitrage" else None,
385
+ "symbol": row["symbol"] if row else None,
386
+ "sector": h.get("industry") or (row or {}).get("sector")}
387
+ items = sorted(merged.values(), key=lambda h: -h["weight"])
388
+ stocks = [h for h in items if h["kind"] in ("equity", "foreign_equity")]
389
+
390
+ mix: dict[str, float] = {}
391
+ for h in items:
392
+ mix[h["kind"]] = mix.get(h["kind"], 0) + h["weight"]
393
+ rest = 100 - sum(mix.values())
394
+ if rest > 0.5: # TREPS, cash and net receivables carry no ISIN
395
+ mix["cash"] = mix.get("cash", 0) + rest
396
+ sectors: dict[str, float] = {}
397
+ for h in stocks:
398
+ sectors[h["sector"] or "Other"] = sectors.get(h["sector"] or "Other", 0) + h["weight"]
399
+
400
+ changes = None
401
+ if prev:
402
+ before: dict[str, float] = {}
403
+ for h in prev["holdings"]:
404
+ if h["kind"] in ("equity", "arbitrage", "foreign_equity"):
405
+ before[h["isin"]] = before.get(h["isin"], 0) + h["weight"]
406
+ now = {h["isin"]: h for h in stocks}
407
+ new = [{"name": h["name"], "symbol": h["symbol"], "weight": h["weight"]}
408
+ for i, h in now.items() if i not in before]
409
+ names = {h["isin"]: h["name"] for h in prev["holdings"]}
410
+ exited = [{"name": names.get(i, i), "weight": w} for i, w in before.items() if i not in now]
411
+ moved = sorted(({"name": h["name"], "symbol": h["symbol"], "from": before[i], "to": h["weight"]}
412
+ for i, h in now.items() if i in before and abs(h["weight"] - before[i]) >= 0.25),
413
+ key=lambda c: -abs(c["to"] - c["from"]))
414
+ changes = {"since": prev.get("as_of"), "new": sorted(new, key=lambda x: -x["weight"])[:15],
415
+ "exited": sorted(exited, key=lambda x: -x["weight"])[:15], "moved": moved[:15]}
416
+
417
+ aum_cr = sum((h.get("value_lakh") or 0) for h in scheme["holdings"]) / 100 or None
418
+
419
+ return {
420
+ "status": "ok", "scheme": scheme["scheme"], "as_of": scheme.get("as_of"),
421
+ "count": len(stocks), "holdings": items, "top": stocks[:TOP_N],
422
+ "cap_split": cap_split(stocks, symbols),
423
+ "top10_weight": sum(h["weight"] for h in stocks[:10]),
424
+ "sectors": [{"name": k, "weight": v} for k, v in sorted(sectors.items(), key=lambda kv: -kv[1])],
425
+ "mix": [{"kind": k, "weight": v} for k, v in sorted(mix.items(), key=lambda kv: -kv[1])],
426
+ "aum_cr": aum_cr,
427
+ "changes": changes, "weights": "reported",
428
+ "concentration": concentration(stocks),
429
+ "churn": churn(scheme["holdings"], prev["holdings"], aum_cr) if prev else None,
430
+ }
431
+
432
+
433
+ EQUITY_KINDS = ("equity", "arbitrage", "foreign_equity")
434
+
435
+
436
+ def concentration(stocks: list[dict]) -> Optional[dict]:
437
+ """How concentrated the stock book is: the effective number of equal-weight
438
+ stocks it behaves like (1 / sum of squared weights) and the largest holding."""
439
+ weights = [h["weight"] / 100 for h in stocks if h.get("weight")]
440
+ if not weights:
441
+ return None
442
+ hhi = sum(w * w for w in weights)
443
+ return {"effective_stocks": 1 / hhi if hhi else None, "top_weight": max(weights) * 100,
444
+ "count": len(weights)}
445
+
446
+
447
+ def churn(now: list[dict], before: list[dict], aum_cr: Optional[float]) -> Optional[dict]:
448
+ """Trading in the month from the change in shares held (not weights, which
449
+ also move with prices), valued at this month's prices. Turnover follows
450
+ SEBI's definition: the smaller of purchases and sales over the fund size."""
451
+ def shares(rows):
452
+ out: dict[str, float] = {}
453
+ for h in rows:
454
+ if h["kind"] in EQUITY_KINDS and h.get("qty"):
455
+ out[h["isin"]] = out.get(h["isin"], 0) + float(h["qty"])
456
+ return out
457
+
458
+ q_now, q_before = shares(now), shares(before)
459
+ price: dict[str, float] = {}
460
+ for h in now:
461
+ if h["kind"] in EQUITY_KINDS and h.get("qty") and h.get("value_lakh"):
462
+ price[h["isin"]] = float(h["value_lakh"]) / float(h["qty"])
463
+ prev_price: dict[str, float] = {}
464
+ for h in before:
465
+ if h["kind"] in EQUITY_KINDS and h.get("qty") and h.get("value_lakh"):
466
+ prev_price[h["isin"]] = float(h["value_lakh"]) / float(h["qty"])
467
+ if not aum_cr or not q_now or not q_before:
468
+ return None
469
+ bought = sold = 0.0 # lakh
470
+ for isin in set(q_now) | set(q_before):
471
+ diff = q_now.get(isin, 0.0) - q_before.get(isin, 0.0)
472
+ px = price.get(isin) or prev_price.get(isin)
473
+ if not px or abs(diff) < 1:
474
+ continue
475
+ if diff > 0:
476
+ bought += diff * px
477
+ else:
478
+ sold += -diff * px
479
+ month = min(bought, sold) / 100 / aum_cr * 100
480
+ return {"month_pct": month, "yearly_pct": month * 12, "bought_cr": bought / 100, "sold_cr": sold / 100}
481
+
482
+
483
+ def _index_fallback(fund: dict) -> Optional[dict]:
484
+ from . import etf
485
+
486
+ name = fund.get("name", "")
487
+ if not re.search(r"\bindex\b|\betf\b|nifty|sensex", name, re.I):
488
+ return None
489
+ _file, index, st = etf.match_index(name)
490
+ if st != "ok":
491
+ return None
492
+ from ..data.listings import get_store
493
+
494
+ rows = get_store().rows("india")
495
+ caps = {r["symbol"]: r["market_cap"] for r in rows if r.get("type") == "stock" and r.get("market_cap")}
496
+ d = etf._india(fund["symbol"], {"name": name}, caps, etf._http)
497
+ if d.get("status") != "ok":
498
+ return None
499
+ hold = [{"name": h["name"], "symbol": h["symbol"], "isin": None, "weight": h["weight"] * 100,
500
+ "sector": h["sector"], "kind": "equity"} for h in d["holdings"]]
501
+ return {"status": "ok", "scheme": name, "as_of": None, "count": len(hold), "holdings": hold, "top": hold[:TOP_N],
502
+ "top10_weight": sum(h["weight"] for h in hold[:10]),
503
+ "sectors": [{"name": s["name"], "weight": s["weight"] * 100} for s in d["sectors"]],
504
+ "mix": [{"kind": "equity", "weight": 100.0}], "changes": None, "aum_cr": None,
505
+ "weights": "estimated", "source": "niftyindices.com",
506
+ "note": f"Tracks the {index}. Weights estimated from market cap; the fund's own file gives exact weights."}
507
+
508
+
509
+ def for_fund(code: str, rows: Optional[list[dict]] = None) -> dict:
510
+ """Holdings of one listed scheme (by AMFI scheme code)."""
511
+ if rows is None:
512
+ from ..data.listings import get_store
513
+
514
+ rows = get_store().rows("india")
515
+ fund = next((r for r in rows if r.get("type") == "mf" and str(r["symbol"]) == str(code)), None)
516
+ if fund is None:
517
+ return {"status": "unknown", "note": "No such scheme."}
518
+ docs = _load(slug(fund.get("amc", "")))
519
+ if docs:
520
+ scheme = match_scheme(fund["name"], docs[0]["schemes"])
521
+ if scheme:
522
+ prev = match_scheme(scheme["scheme"], docs[1]["schemes"]) if len(docs) > 1 else None
523
+ out = _summarise(scheme, prev, _isin_symbols())
524
+ return {**out, "amc": fund.get("amc"), "source": f'{fund.get("amc")} monthly portfolio',
525
+ "note": None}
526
+ fb = _index_fallback(fund)
527
+ if fb:
528
+ return {**fb, "amc": fund.get("amc")}
529
+ have = bool(docs)
530
+ return {"status": "missing", "amc": fund.get("amc"),
531
+ "note": (f"{fund.get('amc')}'s latest portfolio file doesn't list this scheme by name."
532
+ if have else f"{fund.get('amc')}'s monthly portfolio hasn't been loaded yet.")}
533
+
534
+
535
+ def funds_holding(symbol: str, limit: int = 40) -> dict:
536
+ """Which funds hold a stock (latest month of every loaded fund house)."""
537
+ from ..data.listings import get_store
538
+
539
+ rows = get_store().rows("india")
540
+ me = next((r for r in rows if r.get("type") == "stock" and r["symbol"].upper() == symbol.upper()), None)
541
+ if me is None or not me.get("isin"):
542
+ return {"symbol": symbol, "funds": [], "note": "No ISIN for this stock."}
543
+ isin = me["isin"]
544
+ out = []
545
+ for folder in _root().iterdir():
546
+ docs = _load(folder.name) if folder.is_dir() else ()
547
+ if not docs:
548
+ continue
549
+ doc = docs[0]
550
+ prev = {s["scheme"]: s for s in docs[1]["schemes"]} if len(docs) > 1 else {}
551
+ for s in doc["schemes"]:
552
+ w = sum(h["weight"] for h in s["holdings"] if h["isin"] == isin and h["kind"] != "debt")
553
+ if w <= 0:
554
+ continue
555
+ p = prev.get(s["scheme"])
556
+ before = sum(h["weight"] for h in p["holdings"] if h["isin"] == isin) if p else None
557
+ value = sum((h.get("value_lakh") or 0) for h in s["holdings"] if h["isin"] == isin)
558
+ out.append({"amc": doc["amc"], "scheme": s["scheme"], "weight": w, "prev_weight": before,
559
+ "value_cr": value / 100 if value else None, "as_of": s.get("as_of")})
560
+ out.sort(key=lambda f: -(f["value_cr"] or 0))
561
+ return {"symbol": me["symbol"], "isin": isin, "count": len(out), "funds": out[:limit],
562
+ "houses_loaded": sum(1 for p in _root().iterdir() if p.is_dir() and _load(p.name))}
563
+
564
+
565
+ # ---------------------------------------------------------------- auto sources
566
+
567
+ def _http(url: str) -> bytes:
568
+ import requests
569
+
570
+ r = requests.get(url, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/126"}, timeout=60)
571
+ r.raise_for_status()
572
+ return r.content
573
+
574
+
575
+ MONTHS = "january|february|march|april|may|june|july|august|september|october|november|december"
576
+
577
+
578
+ def _ppfas(fetch: Callable[[str], bytes]) -> list[tuple[bytes, str]]:
579
+ base = "https://amc.ppfas.com"
580
+ page = fetch(base + "/downloads/portfolio-disclosure/").decode("utf-8", "ignore")
581
+ links = re.findall(r'href="(/downloads/portfolio-disclosure/\d{4}/[^"]+\.xlsx?)(?:\?[^"]*)?"', page, re.I)
582
+ dated = []
583
+ for link in links:
584
+ m = re.search(rf"({MONTHS})_(\d{{1,2}})_(\d{{4}})", link, re.I)
585
+ if m:
586
+ dated.append((datetime.strptime(f"{m.group(3)} {m.group(1)} {m.group(2)}", "%Y %B %d"), link))
587
+ if not dated:
588
+ raise ValueError("No portfolio links on the PPFAS page")
589
+ latest = max(d for d, _ in dated)
590
+ month = [l for d, l in dated if d == latest]
591
+ combined = [l for l in month if re.search(r"/PPFAS_Monthly", l)]
592
+ return [(fetch(base + l), l) for l in (combined or month)]
593
+
594
+
595
+ # Fund house (as named in the AMFI scheme list) -> downloader for its latest month.
596
+ AUTO_SOURCES: dict[str, Callable[[Callable[[str], bytes]], list[tuple[bytes, str]]]] = {
597
+ "PPFAS Mutual Fund": _ppfas,
598
+ }
599
+
600
+
601
+ def refresh(amc: Optional[str] = None, fetch: Callable[[str], bytes] = _http, force: bool = False) -> list[dict]:
602
+ results = []
603
+ for name, source in AUTO_SOURCES.items():
604
+ if amc and name != amc:
605
+ continue
606
+ docs = _load(slug(name))
607
+ if docs and not force and time.time() - docs[0].get("saved_at", 0) < REFRESH_SECONDS:
608
+ results.append({"amc": name, "skipped": "fresh"})
609
+ continue
610
+ try:
611
+ results.append(ingest_many(name, source(fetch), "auto"))
612
+ except Exception as exc:
613
+ logger.warning("Portfolio refresh for %s failed: %s", name, exc)
614
+ results.append({"amc": name, "error": str(exc)[:200]})
615
+ return results
616
+
617
+
618
+ def refresh_loop() -> None:
619
+ time.sleep(90) # let start-up finish first
620
+ while True:
621
+ try:
622
+ refresh()
623
+ except Exception as exc: # never kill the thread
624
+ logger.warning("Portfolio refresh loop: %s", exc)
625
+ time.sleep(REFRESH_SECONDS)
626
+
627
+
628
+ def pair_overlap(a: Optional[dict], b: Optional[dict]) -> Optional[dict]:
629
+ """Stocks two funds hold in common, from their portfolio files: overlap =
630
+ sum over shared stocks of the smaller weight (the part of the money that
631
+ sits in the same companies)."""
632
+ if not a or not b or a.get("status") != "ok" or b.get("status") != "ok":
633
+ return None
634
+
635
+ def weights(d: dict) -> dict[str, dict]:
636
+ out: dict[str, dict] = {}
637
+ for h in d.get("holdings", []):
638
+ if h.get("kind") in ("equity", "foreign_equity") and h.get("weight"):
639
+ k = h.get("isin") or h.get("symbol") or h.get("name")
640
+ cur = out.setdefault(k, {"name": h.get("name"), "symbol": h.get("symbol"), "weight": 0.0})
641
+ cur["weight"] += h["weight"]
642
+ return out
643
+
644
+ wa, wb = weights(a), weights(b)
645
+ if not wa or not wb:
646
+ return None
647
+ shared = sorted(set(wa) & set(wb), key=lambda k: -min(wa[k]["weight"], wb[k]["weight"]))
648
+ return {"overlap": sum(min(wa[k]["weight"], wb[k]["weight"]) for k in shared),
649
+ "shared_count": len(shared), "only_a": len(set(wa) - set(wb)), "only_b": len(set(wb) - set(wa)),
650
+ "shared": [{"name": wa[k]["name"], "symbol": wa[k]["symbol"], "weight_a": wa[k]["weight"],
651
+ "weight_b": wb[k]["weight"]} for k in shared[:20]]}
652
+
653
+
654
+ def overlap(code_a: str, code_b: str) -> dict:
655
+ a, b = for_fund(code_a), for_fund(code_b)
656
+ out = pair_overlap(a, b)
657
+ missing = [d.get("note") or "Portfolio not available" for d in (a, b) if d.get("status") != "ok"]
658
+ if out and a.get("scheme") and a.get("scheme") == b.get("scheme"):
659
+ missing.append("These are two plans / options of the same scheme: one portfolio, so the overlap is "
660
+ "simply its stock holdings.")
661
+ return {"status": "ok" if out else "missing", "a": {"scheme": a.get("scheme"), "as_of": a.get("as_of")},
662
+ "b": {"scheme": b.get("scheme"), "as_of": b.get("as_of")}, **(out or {}),
663
+ "note": " ".join(missing) or None}
664
+