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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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
|
+
|