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