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,231 @@
|
|
|
1
|
+
"""How expensive the whole market is, against its own history.
|
|
2
|
+
|
|
3
|
+
US: S&P 500 P/E, Shiller P/E (CAPE), dividend yield and earnings yield,
|
|
4
|
+
monthly since 1871, from multpl.com tables (no key). Each reading is placed
|
|
5
|
+
as a percentile of its full history and of the years since 1990.
|
|
6
|
+
|
|
7
|
+
India: P/E, P/B and dividend yield of the main Nifty indices from NSE's
|
|
8
|
+
allIndices feed (current values only). One snapshot a day is kept under
|
|
9
|
+
~/.marketmyna/valuation/, so a percentile appears once a year of history
|
|
10
|
+
has built up; until then the card shows the reading and the date the
|
|
11
|
+
history starts. Failed fetches serve the last good copy marked stale.
|
|
12
|
+
|
|
13
|
+
Readings are descriptive (where today sits in the record), not a forecast.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from datetime import date, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
from marketmyna.core.home import app_home
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
CACHE_SECONDS = 6 * 3600
|
|
35
|
+
MIN_HISTORY_DAYS = 250 # trading days of India snapshots before a percentile is shown
|
|
36
|
+
|
|
37
|
+
US_SERIES = [
|
|
38
|
+
("shiller-pe", "Shiller P/E (CAPE)", "Price over 10-year average inflation-adjusted earnings"),
|
|
39
|
+
("s-p-500-pe-ratio", "S&P 500 P/E", "Price over trailing 12-month earnings"),
|
|
40
|
+
("s-p-500-earnings-yield", "Earnings yield", "Trailing earnings over price, %"),
|
|
41
|
+
("s-p-500-dividend-yield", "Dividend yield", "Trailing dividends over price, %"),
|
|
42
|
+
]
|
|
43
|
+
INDIA_INDICES = ["NIFTY 50", "NIFTY NEXT 50", "NIFTY 500", "NIFTY BANK", "NIFTY MIDCAP 100", "NIFTY SMALLCAP 100"]
|
|
44
|
+
_UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"}
|
|
45
|
+
|
|
46
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
47
|
+
_lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _dir() -> Path:
|
|
51
|
+
path = app_home() / "valuation"
|
|
52
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read(name: str):
|
|
57
|
+
try:
|
|
58
|
+
return json.loads((_dir() / name).read_text(encoding="utf-8"))
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write(name: str, data) -> None:
|
|
64
|
+
try:
|
|
65
|
+
tmp = _dir() / f".{name}.tmp"
|
|
66
|
+
tmp.write_text(json.dumps(data), encoding="utf-8")
|
|
67
|
+
tmp.replace(_dir() / name)
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
logger.warning("Could not save %s: %s", name, exc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- US (multpl)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_multpl(page: str) -> list[tuple[str, float, bool]]:
|
|
76
|
+
"""(ISO date, value, estimated) rows, newest first, from a /table/by-month page."""
|
|
77
|
+
rows = []
|
|
78
|
+
for when, cell in re.findall(r"<td>([A-Z][a-z]{2} \d{1,2}, \d{4})</td>\s*<td>(.*?)</td>", page, re.S):
|
|
79
|
+
text = re.sub(r"<[^>]+>|\s", "", html.unescape(cell)).replace(" ", "")
|
|
80
|
+
estimated = "†" in text # dagger: multpl's estimate marker
|
|
81
|
+
try:
|
|
82
|
+
value = float(text.replace("†", "").replace("%", ""))
|
|
83
|
+
except ValueError:
|
|
84
|
+
continue
|
|
85
|
+
rows.append((datetime.strptime(when, "%b %d, %Y").date().isoformat(), value, estimated))
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _multpl(slug: str) -> str:
|
|
90
|
+
r = requests.get(f"https://www.multpl.com/{slug}/table/by-month", headers=_UA, timeout=20)
|
|
91
|
+
r.raise_for_status()
|
|
92
|
+
return r.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def percentile(values: list[float], value: float) -> Optional[float]:
|
|
96
|
+
"""Share of the history at or below `value`, 0-100."""
|
|
97
|
+
if not values:
|
|
98
|
+
return None
|
|
99
|
+
return round(sum(v <= value for v in values) / len(values) * 100, 1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _median(values: list[float]) -> Optional[float]:
|
|
103
|
+
if not values:
|
|
104
|
+
return None
|
|
105
|
+
s = sorted(values)
|
|
106
|
+
mid = len(s) // 2
|
|
107
|
+
return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def us_series(slug: str, label: str, note: str, rows: list[tuple[str, float, bool]]) -> Optional[dict]:
|
|
111
|
+
if not rows:
|
|
112
|
+
return None
|
|
113
|
+
when, value, estimated = rows[0]
|
|
114
|
+
history = [v for _, v, _ in rows[1:]] # the monthly record, excluding today's reading
|
|
115
|
+
since_1990 = [v for d, v, _ in rows[1:] if d >= "1990-01-01"]
|
|
116
|
+
spark = [{"date": d, "value": v} for d, v, _ in reversed(rows[:241])] # about 20 years, monthly
|
|
117
|
+
return {
|
|
118
|
+
"key": slug, "label": label, "note": note, "value": value, "date": when, "estimated": estimated,
|
|
119
|
+
"percentile_all": percentile(history, value), "percentile_1990": percentile(since_1990, value),
|
|
120
|
+
"median_all": _median(history), "median_1990": _median(since_1990),
|
|
121
|
+
"since": rows[-1][0], "spark": spark,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _us(fetch: Callable[[str], str]) -> dict:
|
|
126
|
+
out, failed = [], []
|
|
127
|
+
for slug, label, note in US_SERIES:
|
|
128
|
+
try:
|
|
129
|
+
rows = parse_multpl(fetch(slug))
|
|
130
|
+
if not rows:
|
|
131
|
+
raise ValueError("no rows")
|
|
132
|
+
_write(f"multpl-{slug}.json", rows)
|
|
133
|
+
stale = False
|
|
134
|
+
except Exception as exc: # noqa: BLE001 - fall back to the last good copy
|
|
135
|
+
logger.info("multpl %s failed: %s", slug, exc)
|
|
136
|
+
rows, stale = [tuple(r) for r in (_read(f"multpl-{slug}.json") or [])], True
|
|
137
|
+
item = us_series(slug, label, note, rows)
|
|
138
|
+
if item:
|
|
139
|
+
item["stale"] = stale
|
|
140
|
+
out.append(item)
|
|
141
|
+
else:
|
|
142
|
+
failed.append(label)
|
|
143
|
+
return {"series": out, "failed": failed, "source": "multpl.com (S&P 500 data back to 1871)"}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ India (NSE)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _nse_indices() -> list[dict]:
|
|
150
|
+
from ..trading.options import _nse
|
|
151
|
+
|
|
152
|
+
return (_nse._get("allIndices", {}) or {}).get("data") or []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _num(v) -> Optional[float]:
|
|
156
|
+
try:
|
|
157
|
+
f = float(v)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
return None
|
|
160
|
+
return f if f > 0 else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _india(fetch: Callable[[], list[dict]], today: Optional[date] = None) -> dict:
|
|
164
|
+
today = today or date.today()
|
|
165
|
+
history: dict[str, dict] = _read("nse-history.json") or {}
|
|
166
|
+
stale = False
|
|
167
|
+
try:
|
|
168
|
+
rows = {r.get("index"): r for r in fetch()}
|
|
169
|
+
snap = {name: {"pe": _num(r.get("pe")), "pb": _num(r.get("pb")), "dy": _num(r.get("dy")),
|
|
170
|
+
"last": _num(r.get("last"))}
|
|
171
|
+
for name in INDIA_INDICES if (r := rows.get(name)) and _num(r.get("pe"))}
|
|
172
|
+
if not snap:
|
|
173
|
+
raise ValueError("NSE returned no index valuations")
|
|
174
|
+
history[today.isoformat()] = snap
|
|
175
|
+
_write("nse-history.json", history)
|
|
176
|
+
except Exception as exc: # noqa: BLE001 - serve the latest saved day
|
|
177
|
+
logger.info("NSE allIndices failed: %s", exc)
|
|
178
|
+
stale = True
|
|
179
|
+
if not history:
|
|
180
|
+
return {"indices": [], "stale": True, "source": "NSE"}
|
|
181
|
+
|
|
182
|
+
days = sorted(history)
|
|
183
|
+
latest_day = days[-1]
|
|
184
|
+
out = []
|
|
185
|
+
for name in INDIA_INDICES:
|
|
186
|
+
now = history[latest_day].get(name)
|
|
187
|
+
if not now:
|
|
188
|
+
continue
|
|
189
|
+
item = {"index": name, "date": latest_day, "last": now.get("last"), "history_from": days[0],
|
|
190
|
+
"history_days": len(days)}
|
|
191
|
+
for metric in ("pe", "pb", "dy"):
|
|
192
|
+
past = [history[d][name][metric] for d in days[:-1] if history[d].get(name, {}).get(metric)]
|
|
193
|
+
item[metric] = now.get(metric)
|
|
194
|
+
item[f"{metric}_percentile"] = (percentile(past, now[metric])
|
|
195
|
+
if now.get(metric) and len(past) >= MIN_HISTORY_DAYS else None)
|
|
196
|
+
year_ago = [d for d in days if d <= (today.replace(year=today.year - 1)).isoformat()]
|
|
197
|
+
item["pe_year_ago"] = history[year_ago[-1]][name]["pe"] if year_ago and name in history[year_ago[-1]] else None
|
|
198
|
+
out.append(item)
|
|
199
|
+
return {"indices": out, "stale": stale, "as_of": latest_day,
|
|
200
|
+
"source": "NSE index P/E, P/B and dividend yield; daily snapshots kept from " + days[0]}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build(fetch_us: Callable[[str], str] = _multpl, fetch_india: Callable[[], list[dict]] = _nse_indices,
|
|
204
|
+
use_cache: bool = True) -> dict:
|
|
205
|
+
if use_cache:
|
|
206
|
+
with _lock:
|
|
207
|
+
hit = _cache.get("all")
|
|
208
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
209
|
+
return hit[1]
|
|
210
|
+
out = {"us": _us(fetch_us), "india": _india(fetch_india), "updated": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
|
211
|
+
if use_cache and (out["us"]["series"] or out["india"]["indices"]):
|
|
212
|
+
with _lock:
|
|
213
|
+
_cache["all"] = (time.time(), out)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def snapshot_loop(check_seconds: int = 3 * 3600) -> None:
|
|
218
|
+
"""Background thread: save today's NSE valuations once, after the close
|
|
219
|
+
(16:00 IST), on weekdays, so the history builds without visitors."""
|
|
220
|
+
from zoneinfo import ZoneInfo
|
|
221
|
+
|
|
222
|
+
time.sleep(180) # let start-up traffic settle
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
now = datetime.now(ZoneInfo("Asia/Kolkata"))
|
|
226
|
+
saved = _read("nse-history.json") or {}
|
|
227
|
+
if now.weekday() < 5 and now.hour >= 16 and now.date().isoformat() not in saved:
|
|
228
|
+
_india(_nse_indices, today=now.date())
|
|
229
|
+
except Exception as exc: # noqa: BLE001 - never kill the thread
|
|
230
|
+
logger.info("Valuation snapshot failed: %s", exc)
|
|
231
|
+
time.sleep(check_seconds)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Mutual fund holdings in the web portfolio: CAS import and live NAVs.
|
|
2
|
+
|
|
3
|
+
A fund holding is an ordinary portfolio holding with market "mf" and the AMFI
|
|
4
|
+
scheme code as its symbol, so the existing transaction ledger, gains and
|
|
5
|
+
delete/replay logic all apply. NAV, name and category come from mfapi.in.
|
|
6
|
+
|
|
7
|
+
CAS import uses casparser (CAMS / KFintech / MFCentral statements, usually
|
|
8
|
+
locked with the investor's PAN). The PDF and password stay in memory only, and
|
|
9
|
+
nothing personal (PAN, email, address) is returned or stored.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
MARKET = "mf"
|
|
22
|
+
_TTL = 15 * 60
|
|
23
|
+
_cache: dict[int, tuple[float, dict | None]] = {}
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def latest(code: int | str) -> dict | None:
|
|
28
|
+
"""Latest NAV plus scheme name / AMC / category, cached 15 minutes."""
|
|
29
|
+
try:
|
|
30
|
+
code = int(code)
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return None
|
|
33
|
+
with _lock:
|
|
34
|
+
hit = _cache.get(code)
|
|
35
|
+
if hit and time.monotonic() - hit[0] < _TTL:
|
|
36
|
+
return hit[1]
|
|
37
|
+
info = None
|
|
38
|
+
try:
|
|
39
|
+
r = requests.get(f"https://api.mfapi.in/mf/{code}/latest", timeout=10)
|
|
40
|
+
r.raise_for_status()
|
|
41
|
+
data = r.json()
|
|
42
|
+
meta, rows = data.get("meta") or {}, data.get("data") or []
|
|
43
|
+
if rows:
|
|
44
|
+
info = {
|
|
45
|
+
"name": meta.get("scheme_name") or str(code),
|
|
46
|
+
"amc": meta.get("fund_house") or "",
|
|
47
|
+
"category": meta.get("scheme_category") or "",
|
|
48
|
+
"nav": float(rows[0]["nav"]),
|
|
49
|
+
"nav_date": datetime.strptime(rows[0]["date"], "%d-%m-%Y").date().isoformat(),
|
|
50
|
+
}
|
|
51
|
+
except Exception:
|
|
52
|
+
if hit: # keep serving the stale value rather than zeroing the holding
|
|
53
|
+
return hit[1]
|
|
54
|
+
with _lock:
|
|
55
|
+
_cache[code] = (time.monotonic(), info)
|
|
56
|
+
return info
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_HIST_TTL = 6 * 3600
|
|
60
|
+
_hist: dict[int, tuple[float, list[tuple[date, float]]]] = {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def nav_on(code: int | str, day: date) -> dict | None:
|
|
64
|
+
"""Official NAV of a scheme on `day`, or the last one published before it
|
|
65
|
+
(weekends and holidays have none). History cached a few hours per scheme."""
|
|
66
|
+
try:
|
|
67
|
+
code = int(code)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
with _lock:
|
|
71
|
+
hit = _hist.get(code)
|
|
72
|
+
if hit and time.monotonic() - hit[0] < _HIST_TTL:
|
|
73
|
+
rows = hit[1]
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
r = requests.get(f"https://api.mfapi.in/mf/{code}", timeout=15)
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
rows = sorted(
|
|
79
|
+
(datetime.strptime(x["date"], "%d-%m-%Y").date(), float(x["nav"]))
|
|
80
|
+
for x in (r.json().get("data") or [])
|
|
81
|
+
)
|
|
82
|
+
except Exception:
|
|
83
|
+
if not hit:
|
|
84
|
+
return None
|
|
85
|
+
rows = hit[1]
|
|
86
|
+
with _lock:
|
|
87
|
+
_hist[code] = (time.monotonic(), rows)
|
|
88
|
+
before = [(d, nav) for d, nav in rows if d <= day]
|
|
89
|
+
if not before:
|
|
90
|
+
return None
|
|
91
|
+
d, nav = before[-1]
|
|
92
|
+
return {"nav": nav, "nav_date": d.isoformat()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CASError(ValueError):
|
|
96
|
+
"""The statement could not be read (wrong password, not a CAS, ...)."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_cas(pdf: bytes, password: str) -> dict:
|
|
100
|
+
"""Read a CAS PDF into per-scheme holdings (aggregated across folios)."""
|
|
101
|
+
import casparser
|
|
102
|
+
from casparser.exceptions import IncorrectPasswordError, ParserException
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
cas = casparser.read_cas_pdf(io.BytesIO(pdf), password)
|
|
106
|
+
except IncorrectPasswordError as exc:
|
|
107
|
+
raise CASError("Wrong password. CAS statements are usually locked with your PAN in capitals.") from exc
|
|
108
|
+
except ParserException as exc:
|
|
109
|
+
raise CASError(f"Could not read this statement: {exc}") from exc
|
|
110
|
+
except Exception as exc: # malformed PDF etc
|
|
111
|
+
raise CASError(f"Could not read this file as a CAS statement ({type(exc).__name__}).") from exc
|
|
112
|
+
if not hasattr(cas, "folios"):
|
|
113
|
+
raise CASError("Depository (NSDL / CDSL) statements are not supported yet. "
|
|
114
|
+
"Use the CAMS / KFintech CAS from camsonline.com or MFCentral.")
|
|
115
|
+
return summarise(cas)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def summarise(cas) -> dict:
|
|
119
|
+
"""CASData -> holdings rows; split out so tests can feed a fake statement."""
|
|
120
|
+
rows: dict[str, dict] = {}
|
|
121
|
+
skipped: list[dict] = []
|
|
122
|
+
for folio in cas.folios:
|
|
123
|
+
for s in folio.schemes:
|
|
124
|
+
units = max(float(s.close or 0), 0.0) # 0 = fully redeemed, still syncs a sale
|
|
125
|
+
if not s.amfi:
|
|
126
|
+
if units <= 0:
|
|
127
|
+
continue
|
|
128
|
+
skipped.append({"scheme": s.scheme, "reason": "No AMFI code, cannot price it"})
|
|
129
|
+
continue
|
|
130
|
+
v = s.valuation
|
|
131
|
+
cost = float(v.cost) if v.cost is not None else None
|
|
132
|
+
row = rows.setdefault(s.amfi, {
|
|
133
|
+
"amfi": str(s.amfi), "isin": s.isin, "scheme": s.scheme, "amc": folio.amc,
|
|
134
|
+
"folios": 0, "units": 0.0, "cost": 0.0, "cost_known": True,
|
|
135
|
+
"value": 0.0, "nav": float(v.nav), "date": _iso(v.date),
|
|
136
|
+
})
|
|
137
|
+
row["folios"] += 1
|
|
138
|
+
row["units"] += units
|
|
139
|
+
row["value"] += float(v.value)
|
|
140
|
+
if cost is None:
|
|
141
|
+
row["cost_known"] = False
|
|
142
|
+
else:
|
|
143
|
+
row["cost"] += cost
|
|
144
|
+
holdings = sorted((r for r in rows.values()), key=lambda r: (r["units"] <= 0, -r["value"]))
|
|
145
|
+
for r in holdings:
|
|
146
|
+
if not r["cost_known"]:
|
|
147
|
+
r["cost"] = None
|
|
148
|
+
period = getattr(cas, "statement_period", None)
|
|
149
|
+
return {
|
|
150
|
+
"statement_to": getattr(period, "to", None),
|
|
151
|
+
"cas_type": str(getattr(cas, "cas_type", "") or ""),
|
|
152
|
+
"holdings": holdings,
|
|
153
|
+
"skipped": skipped,
|
|
154
|
+
"total_value": sum(r["value"] for r in holdings),
|
|
155
|
+
"warnings": list(getattr(cas, "parse_warnings", []) or []),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def plan_import(held: dict[str, float], rows: list[dict]) -> list[dict]:
|
|
160
|
+
"""Transactions that bring each scheme's units to the statement's balance.
|
|
161
|
+
|
|
162
|
+
New scheme: one BUY at the statement cost (or NAV when cost is unknown).
|
|
163
|
+
Already held: BUY / SELL only the unit difference at the statement NAV, so a
|
|
164
|
+
monthly re-import stays in sync without double counting.
|
|
165
|
+
"""
|
|
166
|
+
out = []
|
|
167
|
+
for r in rows:
|
|
168
|
+
code, units = str(r["amfi"]), float(r["units"])
|
|
169
|
+
have = float(held.get(code, 0.0))
|
|
170
|
+
diff = -have if units <= 1e-6 else units - have # full exit sells exactly what is held
|
|
171
|
+
if abs(diff) < 1e-3:
|
|
172
|
+
continue
|
|
173
|
+
when = r.get("date") or date.today().isoformat()
|
|
174
|
+
if have <= 0 and r.get("cost") and units > 0:
|
|
175
|
+
price = float(r["cost"]) / units
|
|
176
|
+
else:
|
|
177
|
+
price = float(r["nav"])
|
|
178
|
+
out.append({
|
|
179
|
+
"symbol": code,
|
|
180
|
+
"type": "buy" if diff > 0 else "sell",
|
|
181
|
+
"quantity": round(diff, 6) if diff > 0 else min(-diff, have),
|
|
182
|
+
"price": price,
|
|
183
|
+
"date": when,
|
|
184
|
+
"notes": f"CAS import: {r.get('scheme', code)}"[:200],
|
|
185
|
+
})
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _iso(d) -> str | None:
|
|
190
|
+
if d is None:
|
|
191
|
+
return None
|
|
192
|
+
if isinstance(d, (date, datetime)):
|
|
193
|
+
return d.isoformat()[:10]
|
|
194
|
+
for fmt in ("%d-%b-%Y", "%Y-%m-%d", "%d-%m-%Y"):
|
|
195
|
+
try:
|
|
196
|
+
return datetime.strptime(str(d), fmt).date().isoformat()
|
|
197
|
+
except ValueError:
|
|
198
|
+
continue
|
|
199
|
+
return None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Health check for the mutual funds in a portfolio.
|
|
2
|
+
|
|
3
|
+
Works without factsheet PDFs: fund stock lists have no free source, so overlap is estimated
|
|
4
|
+
from how closely the funds' daily NAV returns move together, plus funds that
|
|
5
|
+
sit in the same SEBI category. Category labels come from mfapi.in, e.g.
|
|
6
|
+
"Equity Scheme - Mid Cap Fund".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
# Indian equity funds mostly correlate 0.85-0.93 with each other, so only near-identical
|
|
14
|
+
# daily moves count as overlap; the related band is listed but not penalised.
|
|
15
|
+
SIMILAR = 0.95
|
|
16
|
+
RELATED = 0.85
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_DEBT_WORDS = ("gilt", "bond", "sdl", "debt", "g-sec", "gsec", "liquid", "money market", "psu", "crisil ibx", "t-bill")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def asset_class(category: str, name: str = "") -> str:
|
|
23
|
+
c, n = (category or "").lower(), (name or "").lower()
|
|
24
|
+
if c.startswith("equity"):
|
|
25
|
+
return "Equity"
|
|
26
|
+
if c.startswith("debt"):
|
|
27
|
+
return "Debt"
|
|
28
|
+
if c.startswith("hybrid"):
|
|
29
|
+
return "Hybrid"
|
|
30
|
+
if "index" in c or "etf" in c: # SEBI files index funds under "Other"; the name tells equity from debt
|
|
31
|
+
if any(w in n for w in _DEBT_WORDS):
|
|
32
|
+
return "Debt"
|
|
33
|
+
if "gold" in n or "silver" in n:
|
|
34
|
+
return "Commodity"
|
|
35
|
+
return "Equity"
|
|
36
|
+
if "fof" in c or "fund of funds" in c:
|
|
37
|
+
return "Fund of funds"
|
|
38
|
+
if c.startswith("solution"):
|
|
39
|
+
return "Solution oriented"
|
|
40
|
+
return "Other"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sub_category(category: str) -> str:
|
|
44
|
+
"""'Equity Scheme - Mid Cap Fund' -> 'Mid Cap Fund'."""
|
|
45
|
+
return (category or "").split(" - ", 1)[-1].strip() or "Unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def insights(funds: list[dict], navs: dict[str, pd.Series]) -> dict:
|
|
49
|
+
"""funds: [{symbol, name, category, market_value}]; navs: symbol -> NAV series."""
|
|
50
|
+
funds = [f for f in funds if (f.get("market_value") or 0) > 0]
|
|
51
|
+
total = sum(float(f["market_value"]) for f in funds)
|
|
52
|
+
if not funds or total <= 0:
|
|
53
|
+
return {"funds": 0}
|
|
54
|
+
|
|
55
|
+
def short(f: dict) -> str:
|
|
56
|
+
return (f.get("name") or f["symbol"]).split(" - ")[0]
|
|
57
|
+
|
|
58
|
+
by_class: dict[str, float] = {}
|
|
59
|
+
by_sub: dict[str, list[dict]] = {}
|
|
60
|
+
for f in funds:
|
|
61
|
+
w = float(f["market_value"]) / total
|
|
62
|
+
cls = asset_class(f.get("category", ""), f.get("name", ""))
|
|
63
|
+
by_class[cls] = by_class.get(cls, 0) + w
|
|
64
|
+
by_sub.setdefault(sub_category(f.get("category", "")), []).append(f)
|
|
65
|
+
|
|
66
|
+
issues: list[str] = []
|
|
67
|
+
kinds: list[str] = [] # parallel to issues, for the portfolio review
|
|
68
|
+
score = 100
|
|
69
|
+
|
|
70
|
+
top_class, top_w = max(by_class.items(), key=lambda kv: kv[1])
|
|
71
|
+
if len(funds) >= 3 and top_w > 0.9 and top_class not in ("Hybrid", "Fund of funds"):
|
|
72
|
+
score -= 10
|
|
73
|
+
kinds.append("one_asset_class")
|
|
74
|
+
issues.append(f"{top_w:.0%} is in {top_class} funds. Fine for a long horizon, "
|
|
75
|
+
"but there is no cushion from debt or hybrid funds.")
|
|
76
|
+
|
|
77
|
+
same = [(sub, fs) for sub, fs in by_sub.items() if len(fs) > 1 and sub != "Unknown"]
|
|
78
|
+
for sub, fs in same:
|
|
79
|
+
score -= 8
|
|
80
|
+
kinds.append("same_category")
|
|
81
|
+
issues.append(f"{len(fs)} funds in the same category ({sub}): {', '.join(short(f) for f in fs)}. "
|
|
82
|
+
"They likely hold similar stocks; one may be enough.")
|
|
83
|
+
|
|
84
|
+
pairs = []
|
|
85
|
+
rets = pd.DataFrame({s: v.pct_change() for s, v in navs.items() if s in {f["symbol"] for f in funds}}).dropna()
|
|
86
|
+
if rets.shape[1] >= 2 and len(rets) >= 60:
|
|
87
|
+
corr = rets.corr()
|
|
88
|
+
names = {f["symbol"]: short(f) for f in funds}
|
|
89
|
+
cols = list(corr.columns)
|
|
90
|
+
for i, a in enumerate(cols):
|
|
91
|
+
for b in cols[i + 1:]:
|
|
92
|
+
c = float(corr.loc[a, b])
|
|
93
|
+
if c >= RELATED:
|
|
94
|
+
pairs.append({"a": a, "b": b, "a_name": names[a], "b_name": names[b], "correlation": c})
|
|
95
|
+
pairs.sort(key=lambda p: -p["correlation"])
|
|
96
|
+
similar = [p for p in pairs if p["correlation"] >= SIMILAR]
|
|
97
|
+
if similar:
|
|
98
|
+
score -= min(24, 6 * len(similar))
|
|
99
|
+
p = similar[0]
|
|
100
|
+
kinds.append("lockstep")
|
|
101
|
+
issues.append(f"{len(similar)} fund pair{'s move' if len(similar) > 1 else ' moves'} almost in lockstep "
|
|
102
|
+
f"(e.g. {p['a_name']} and {p['b_name']}, correlation {p['correlation']:.2f}), "
|
|
103
|
+
"so they add little diversification.")
|
|
104
|
+
|
|
105
|
+
regular = [f for f in funds if "regular" in (f.get("name") or "").lower()]
|
|
106
|
+
if regular:
|
|
107
|
+
score -= 5 * len(regular)
|
|
108
|
+
kinds.append("regular_plan")
|
|
109
|
+
issues.append(f"{len(regular)} Regular-plan fund{'s' if len(regular) > 1 else ''} "
|
|
110
|
+
f"({', '.join(short(f) for f in regular)}). Direct plans of the same fund "
|
|
111
|
+
"charge less (often 0.5-1% a year), which compounds.")
|
|
112
|
+
|
|
113
|
+
idcw = [f for f in funds if any(k in (f.get("name") or "").lower() for k in ("idcw", "dividend"))]
|
|
114
|
+
if idcw:
|
|
115
|
+
kinds.append("idcw")
|
|
116
|
+
issues.append(f"{len(idcw)} IDCW / dividend option fund{'s' if len(idcw) > 1 else ''}: payouts are taxed "
|
|
117
|
+
"at your slab rate; Growth options usually suit long-term investing better.")
|
|
118
|
+
|
|
119
|
+
score = max(0, min(100, score))
|
|
120
|
+
return {
|
|
121
|
+
"funds": len(funds),
|
|
122
|
+
"total_value": total,
|
|
123
|
+
"score": score,
|
|
124
|
+
"grade": "Good" if score >= 80 else "Fair" if score >= 60 else "Needs attention",
|
|
125
|
+
"asset_classes": [{"name": k, "weight": v} for k, v in sorted(by_class.items(), key=lambda kv: -kv[1])],
|
|
126
|
+
"categories": [{"name": k, "weight": sum(float(f["market_value"]) for f in fs) / total, "count": len(fs)}
|
|
127
|
+
for k, fs in sorted(by_sub.items(), key=lambda kv: -sum(float(f["market_value"]) for f in kv[1]))],
|
|
128
|
+
"similar_pairs": pairs[:10],
|
|
129
|
+
"issues": issues,
|
|
130
|
+
"issue_kinds": kinds,
|
|
131
|
+
"method": "Overlap is estimated from 1 year of daily NAV moves and shared categories, not from fund stock lists.",
|
|
132
|
+
}
|