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,181 @@
|
|
|
1
|
+
"""AI review of any mutual fund on the Analyze page.
|
|
2
|
+
|
|
3
|
+
Reads the same figures the fund page shows (rolling returns and risk against
|
|
4
|
+
the category benchmark, rank among competitors, style, holdings, Direct vs
|
|
5
|
+
Regular cost), condenses them into a short fact sheet and asks the model to
|
|
6
|
+
explain them. Wording follows marketmyna/core/compliance.py: the owner gets a view,
|
|
7
|
+
public users an explanation without a recommendation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
CACHE_SECONDS = 6 * 3600 # the inputs change monthly (holdings) or daily (NAV)
|
|
22
|
+
# Reasoning models (gpt-oss, Gemini thinking) spend part of max_tokens before
|
|
23
|
+
# answering; too tight a budget cuts the JSON off mid-way.
|
|
24
|
+
MAX_TOKENS = 3000
|
|
25
|
+
_cache: dict[tuple, tuple[float, dict]] = {}
|
|
26
|
+
_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _r(x, nd=1):
|
|
31
|
+
return round(x, nd) if isinstance(x, (int, float)) else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pct(x, nd=1):
|
|
35
|
+
"""A 0-1 fraction as a percentage."""
|
|
36
|
+
return round(x * 100, nd) if isinstance(x, (int, float)) else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def facts(code: str) -> dict:
|
|
40
|
+
"""The fund's numbers, trimmed to what an explanation needs. Each part is
|
|
41
|
+
optional: a source that fails just leaves its section out."""
|
|
42
|
+
from . import fund_analytics, fund_cost, fund_holdings, fund_peers, fund_style
|
|
43
|
+
|
|
44
|
+
parts = {"analytics": fund_analytics.build, "peers": fund_peers.compare, "style": fund_style.build,
|
|
45
|
+
"cost": fund_cost.build, "holdings": fund_holdings.for_fund}
|
|
46
|
+
|
|
47
|
+
def run(fn):
|
|
48
|
+
try:
|
|
49
|
+
out = fn(code)
|
|
50
|
+
return out if isinstance(out, dict) and out.get("status") == "ok" else None
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
logger.info("Fund AI facts: %s failed for %s: %s", fn.__module__, code, exc)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
with ThreadPoolExecutor(max_workers=len(parts)) as pool:
|
|
56
|
+
got = dict(zip(parts, pool.map(run, parts.values())))
|
|
57
|
+
|
|
58
|
+
out: dict = {"code": code}
|
|
59
|
+
peers, a, st, cost, h = got["peers"], got["analytics"], got["style"], got["cost"], got["holdings"]
|
|
60
|
+
if peers:
|
|
61
|
+
out.update(name=peers.get("name"), category=peers.get("category"), plan=peers.get("plan"))
|
|
62
|
+
out["rank_among_peers"] = {
|
|
63
|
+
k: {"rank": v["rank"], "of": v["of"]} for k, v in (peers.get("ranks") or {}).items() if v and v.get("rank")
|
|
64
|
+
}
|
|
65
|
+
out["rank_notes"] = "r1/r3/r5 = 1/3/5-year return; volatility and drawdown rank 1 = least risky"
|
|
66
|
+
if a:
|
|
67
|
+
out.setdefault("category", a.get("category"))
|
|
68
|
+
out["nav_history_from"] = a.get("nav_from")
|
|
69
|
+
out["benchmark"] = (a.get("benchmark") or {}).get("name")
|
|
70
|
+
out["rolling_returns_pct"] = [
|
|
71
|
+
{"years": w["years"], "median": _r(w.get("median")), "benchmark_median": _r(w.get("bench_median")),
|
|
72
|
+
"beat_benchmark_pct_of_windows": _r(w.get("beat_pct")), "negative_pct_of_windows": _r(w.get("negative_pct")),
|
|
73
|
+
"worst": _r(w.get("min")), "best": _r(w.get("max"))}
|
|
74
|
+
for w in (a.get("rolling") or {}).get("windows", [])
|
|
75
|
+
]
|
|
76
|
+
r = a.get("risk") or {}
|
|
77
|
+
if r:
|
|
78
|
+
out[f"risk_last_{r.get('years', 3)}y"] = {
|
|
79
|
+
"annual_return_pct": _pct(r.get("annual_return")), "benchmark_return_pct": _pct(r.get("bench_annual_return")),
|
|
80
|
+
"volatility_pct": _pct(r.get("volatility")), "benchmark_volatility_pct": _pct(r.get("bench_volatility")),
|
|
81
|
+
"max_drawdown_pct": _pct(r.get("max_drawdown")), "benchmark_max_drawdown_pct": _pct(r.get("bench_max_drawdown")),
|
|
82
|
+
"sharpe": _r(r.get("sharpe"), 2), "sortino": _r(r.get("sortino"), 2), "beta": _r(r.get("beta"), 2),
|
|
83
|
+
"alpha_pct": _pct(r.get("alpha")), "up_capture_pct": _pct(r.get("up_capture"), 0),
|
|
84
|
+
"down_capture_pct": _pct(r.get("down_capture"), 0),
|
|
85
|
+
}
|
|
86
|
+
d = a.get("drawdowns") or {}
|
|
87
|
+
if d:
|
|
88
|
+
out["drawdown_pct"] = {"now_from_peak": _r(d.get("current")), "worst_ever": _r(d.get("max"))}
|
|
89
|
+
if st:
|
|
90
|
+
out["style"] = {
|
|
91
|
+
"label": (st.get("style") or {}).get("label"), "size": st.get("size"),
|
|
92
|
+
"pe": _r((st.get("fund") or {}).get("pe")), "market_pe": _r((st.get("market") or {}).get("pe")),
|
|
93
|
+
"equity_pct": _r(st.get("equity_pct")), "cap_split_pct": {k: _r(v) for k, v in (st.get("split") or {}).items() if v},
|
|
94
|
+
"category_rules_failed": [c["rule"] for c in st.get("checks") or [] if not c.get("ok")],
|
|
95
|
+
}
|
|
96
|
+
if h:
|
|
97
|
+
out["portfolio"] = {
|
|
98
|
+
"as_of": h.get("as_of"), "aum_crore": _r(h.get("aum_cr"), 0), "stocks": h.get("count"),
|
|
99
|
+
"top10_weight_pct": _r(h.get("top10_weight")),
|
|
100
|
+
"effective_stocks": _r((h.get("concentration") or {}).get("effective_stocks"), 0),
|
|
101
|
+
"top_holdings": [f"{x['name']} {_r(x.get('weight'))}%" for x in (h.get("top") or [])[:5]],
|
|
102
|
+
"top_sectors": [f"{x['name']} {_r(x.get('weight'))}%" for x in (h.get("sectors") or [])[:5]],
|
|
103
|
+
"asset_mix": [f"{x['kind']} {_r(x.get('weight'))}%" for x in (h.get("mix") or [])[:4]],
|
|
104
|
+
}
|
|
105
|
+
out.setdefault("name", h.get("scheme"))
|
|
106
|
+
if cost:
|
|
107
|
+
p = cost.get("plans") or {}
|
|
108
|
+
if p.get("gap") is not None:
|
|
109
|
+
out["direct_vs_regular"] = {"years": p.get("years"), "direct_return_pct": _r(p.get("direct")),
|
|
110
|
+
"regular_return_pct": _r(p.get("regular")), "yearly_gap_pct": _r(p.get("gap"), 2)}
|
|
111
|
+
out["tax"] = (cost.get("tax") or {}).get("text")
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _system() -> str:
|
|
116
|
+
shape = ('{"summary": "3-4 sentences", "strengths": ["2-4 short points"], "concerns": ["2-4 short points"], '
|
|
117
|
+
'"sections": {"returns": "1-2 sentences", "risk": "1-2 sentences", "portfolio": "1-2 sentences", '
|
|
118
|
+
'"peers": "1-2 sentences", "cost": "1 sentence"}')
|
|
119
|
+
return (
|
|
120
|
+
"You explain mutual fund data to retail investors for an analytics tool that is not registered with SEBI as "
|
|
121
|
+
"an investment adviser or research analyst. Using only the figures given, explain how the fund has behaved: "
|
|
122
|
+
"returns over rolling periods against its benchmark, risk, where it ranks among competitors, what it holds "
|
|
123
|
+
"and what it costs. Describe observations only. Never recommend investing, buying, holding, switching or "
|
|
124
|
+
"selling, give no view on whether it is a good choice, and do not call it attractive, cheap, expensive, "
|
|
125
|
+
"undervalued or overvalued. Reply with ONLY a JSON object: " + shape
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse(text: str) -> Optional[dict]:
|
|
130
|
+
from ..llm.json_reply import parse_object
|
|
131
|
+
|
|
132
|
+
data = parse_object(text)
|
|
133
|
+
if not data or not data.get("summary"):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def points(key):
|
|
137
|
+
return [str(x) for x in (data.get(key) or []) if x][:4]
|
|
138
|
+
|
|
139
|
+
sections = data.get("sections") if isinstance(data.get("sections"), dict) else {}
|
|
140
|
+
out = {
|
|
141
|
+
"summary": str(data["summary"]),
|
|
142
|
+
"strengths": points("strengths"),
|
|
143
|
+
"concerns": points("concerns"),
|
|
144
|
+
"sections": {k: str(sections[k]) for k in ("returns", "risk", "portfolio", "peers", "cost") if sections.get(k)},
|
|
145
|
+
"view": None, # no verdict on the fund (marketmyna/core/compliance.py)
|
|
146
|
+
"suits": None,
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def review(code: str, config, use_cache: bool = True) -> Optional[dict]:
|
|
152
|
+
"""The AI review, or None when the model's reply can't be used. Raises
|
|
153
|
+
LookupError when there is no fund data at all, and lets GenerationError
|
|
154
|
+
(no key / quota) through for the endpoint to report."""
|
|
155
|
+
from ..llm.backend_factory import create_generation_backend
|
|
156
|
+
from ..llm.protocol import GenerationConfig
|
|
157
|
+
|
|
158
|
+
key = code
|
|
159
|
+
if use_cache:
|
|
160
|
+
with _lock:
|
|
161
|
+
hit = _cache.get(key)
|
|
162
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
163
|
+
return hit[1]
|
|
164
|
+
|
|
165
|
+
sheet = facts(code)
|
|
166
|
+
if len(sheet) <= 2: # nothing beyond the code (and maybe a name)
|
|
167
|
+
raise LookupError("No data for this fund yet, so there is nothing to review.")
|
|
168
|
+
prompt = "Fund data (JSON):\n" + json.dumps(sheet, default=str)
|
|
169
|
+
result = create_generation_backend(config).generate(
|
|
170
|
+
prompt, GenerationConfig(max_tokens=MAX_TOKENS, timeout=120), system_prompt=_system())
|
|
171
|
+
out = _parse(result.text)
|
|
172
|
+
if out is None:
|
|
173
|
+
text = result.text or ""
|
|
174
|
+
logger.warning("%s: unusable reply from %s (%d chars): %s ... %s", "Fund AI review", result.model,
|
|
175
|
+
len(text), " ".join(text[:150].split()), " ".join(text[-150:].split()))
|
|
176
|
+
return None
|
|
177
|
+
out.update(name=sheet.get("name"), category=sheet.get("category"), generated_at=time.strftime("%Y-%m-%d %H:%M"),
|
|
178
|
+
compliance="analytics")
|
|
179
|
+
with _lock:
|
|
180
|
+
_cache[key] = (time.time(), out)
|
|
181
|
+
return out
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Deeper analysis of one mutual fund from its daily NAV history:
|
|
2
|
+
|
|
3
|
+
rolling CAGR over every 1 / 3 / 5-year window (one per start day),
|
|
4
|
+
its spread, and how often the fund beat its benchmark over the
|
|
5
|
+
same windows - what point-to-point returns hide.
|
|
6
|
+
risk last 3 years against the category benchmark: beta, alpha,
|
|
7
|
+
Sharpe, Sortino, tracking error, information ratio, up / down
|
|
8
|
+
capture (same maths as the portfolio page).
|
|
9
|
+
drawdowns the deepest falls from a high, how long the recovery took,
|
|
10
|
+
and how the fund did in the 2020 and 2022 sell-offs.
|
|
11
|
+
|
|
12
|
+
The benchmark follows SEBI's tier-1 index for the category (Nifty 100 for
|
|
13
|
+
large caps, Midcap 150, Smallcap 250, Nifty 500 for the broad equity
|
|
14
|
+
categories); hybrid, debt and fund-of-funds categories have no free index and
|
|
15
|
+
are shown without one. A NAV includes reinvested dividends but Yahoo's index
|
|
16
|
+
series are price-only, so each index gets its dividends back as a steady
|
|
17
|
+
yearly rate: the gap between the index and an ETF tracking it (an ETF's
|
|
18
|
+
dividend-adjusted price follows the total-return index), measured over the
|
|
19
|
+
years both exist. Indices without a long-lived ETF borrow a sister index's gap.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Callable, Optional
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import pandas as pd
|
|
32
|
+
|
|
33
|
+
from ..core.numbers import finite
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
CACHE_SECONDS = 12 * 3600
|
|
38
|
+
WINDOWS = (1, 3, 5) # rolling-return windows, years
|
|
39
|
+
RISK_YEARS = 3
|
|
40
|
+
RISK_FREE = 0.065 # Indian T-bill ballpark, a year
|
|
41
|
+
TRADING_DAYS = 252
|
|
42
|
+
TOP_DRAWDOWNS = 5
|
|
43
|
+
MIN_DRAWDOWN = 0.05 # falls shallower than 5% are not listed
|
|
44
|
+
EPISODES = [ # (label, from, to): market sell-offs to compare the fund in
|
|
45
|
+
("2020 Covid crash", "2020-01-14", "2020-03-31"),
|
|
46
|
+
("2022 rate-rise sell-off", "2021-10-01", "2022-10-31"),
|
|
47
|
+
("2024-25 correction", "2024-09-26", "2025-03-04"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
NIFTY_50 = ("^NSEI", "Nifty 50")
|
|
51
|
+
NIFTY_100 = ("^CNX100", "Nifty 100")
|
|
52
|
+
NIFTY_500 = ("^CRSLDX", "Nifty 500")
|
|
53
|
+
NEXT_50 = ("^NSMIDCP", "Nifty Next 50")
|
|
54
|
+
MIDCAP_150 = ("NIFTYMIDCAP150.NS", "Nifty Midcap 150")
|
|
55
|
+
MIDCAP_50 = ("^NSEMDCP50", "Nifty Midcap 50")
|
|
56
|
+
SMALLCAP_250 = ("NIFTYSMLCAP250.NS", "Nifty Smallcap 250")
|
|
57
|
+
SENSEX = ("^BSESN", "Sensex")
|
|
58
|
+
|
|
59
|
+
# Index funds / ETFs: the index named in the scheme (checked in order).
|
|
60
|
+
INDEX_NAMES = [("next 50", NEXT_50), ("midcap 150", MIDCAP_150), ("midcap 50", MIDCAP_50),
|
|
61
|
+
("smallcap 250", SMALLCAP_250), ("nifty 500", NIFTY_500), ("nifty 100", NIFTY_100),
|
|
62
|
+
("nifty 50", NIFTY_50), ("sensex", SENSEX)]
|
|
63
|
+
BROAD_EQUITY = ("flexi cap", "multi cap", "focused", "elss", "value", "contra", "dividend yield", "sectoral",
|
|
64
|
+
"thematic", "equity funds", "growth (close ended)")
|
|
65
|
+
|
|
66
|
+
# price index -> ETF whose dividend-adjusted price gives that index's dividend yield
|
|
67
|
+
# (the most liquid, closest-tracking ETFs; smaller ones are too noisy)
|
|
68
|
+
TR_PROXY = {"^NSEI": "NIFTYBEES.NS", "NIFTYMIDCAP150.NS": "MID150BEES.NS"}
|
|
69
|
+
# other indices borrow the yield of the closest of those two
|
|
70
|
+
TR_SISTER = {"^CNX100": "^NSEI", "^CRSLDX": "^NSEI", "^NSMIDCP": "^NSEI", "^BSESN": "^NSEI",
|
|
71
|
+
"NIFTYSMLCAP250.NS": "NIFTYMIDCAP150.NS", "^NSEMDCP50": "NIFTYMIDCAP150.NS"}
|
|
72
|
+
YIELD_YEARS = 5 # recent years only: ETF fees were higher a decade ago
|
|
73
|
+
MAX_YIELD = 0.025 # a dividend gap above 2.5% a year is a data problem
|
|
74
|
+
JUMP = 0.4 # a one-day log move this big in a NAV is a data break, not a market move
|
|
75
|
+
|
|
76
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
77
|
+
_lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
NavFetch = Callable[[str], Optional[pd.Series]]
|
|
80
|
+
BenchFetch = Callable[[str], Optional[pd.Series]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def benchmark_for(category: Optional[str], name: str = "") -> Optional[tuple[str, str]]:
|
|
84
|
+
"""(Yahoo ticker, label) of the index to judge this fund against, or None."""
|
|
85
|
+
cat, nm = (category or "").lower(), (name or "").lower()
|
|
86
|
+
if "index" in cat or "etf" in cat:
|
|
87
|
+
return next((b for key, b in INDEX_NAMES if key in nm), None)
|
|
88
|
+
if "large & mid" in cat:
|
|
89
|
+
return NIFTY_500 # Nifty LargeMidcap 250 has no free daily series
|
|
90
|
+
if "large cap" in cat:
|
|
91
|
+
return NIFTY_100
|
|
92
|
+
if "mid cap" in cat:
|
|
93
|
+
return MIDCAP_150
|
|
94
|
+
if "small cap" in cat:
|
|
95
|
+
return SMALLCAP_250
|
|
96
|
+
if any(k in cat for k in BROAD_EQUITY):
|
|
97
|
+
return NIFTY_500
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def clean_nav(nav: pd.Series) -> pd.Series:
|
|
102
|
+
"""Fix NAV data breaks: a one-day spike that reverts is dropped; a lasting
|
|
103
|
+
jump (face-value change, e.g. 28.56 -> 2857.24) rescales the history before
|
|
104
|
+
it, so returns across the change stay right."""
|
|
105
|
+
s = nav.astype(float)
|
|
106
|
+
s = s[s > 0].sort_index()
|
|
107
|
+
s = s[~s.index.duplicated(keep="last")]
|
|
108
|
+
for _ in range(10):
|
|
109
|
+
lr = np.log(s / s.shift(1))
|
|
110
|
+
jumps = lr[lr.abs() > JUMP]
|
|
111
|
+
if jumps.empty:
|
|
112
|
+
break
|
|
113
|
+
i = s.index.get_loc(jumps.index[0])
|
|
114
|
+
nxt = float(np.log(s.iloc[i + 1] / s.iloc[i])) if i + 1 < len(s) else 0.0
|
|
115
|
+
if abs(nxt) > JUMP and np.sign(nxt) != np.sign(jumps.iloc[0]):
|
|
116
|
+
s = s.drop(s.index[i])
|
|
117
|
+
else:
|
|
118
|
+
s.iloc[:i] = s.iloc[:i] * (s.iloc[i] / s.iloc[i - 1])
|
|
119
|
+
return s
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _daily(s: pd.Series) -> pd.Series:
|
|
123
|
+
"""Calendar-daily, forward-filled: lets a window be exactly N years."""
|
|
124
|
+
s = s.sort_index()
|
|
125
|
+
s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
|
|
126
|
+
s = s[~s.index.duplicated(keep="last")]
|
|
127
|
+
return s.asfreq("D").ffill()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cagr_series(daily: pd.Series, years: int) -> pd.Series:
|
|
131
|
+
"""CAGR (%) of the window starting on each day, indexed by start day."""
|
|
132
|
+
days = round(365.25 * years)
|
|
133
|
+
end = daily.shift(-days)
|
|
134
|
+
return ((end / daily) ** (1 / years) - 1).dropna() * 100
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rolling(nav: pd.Series, bench: Optional[pd.Series] = None) -> dict:
|
|
138
|
+
fund = _daily(nav)
|
|
139
|
+
idx = _daily(bench) if bench is not None and len(bench) else None
|
|
140
|
+
out = []
|
|
141
|
+
for years in WINDOWS:
|
|
142
|
+
f = _cagr_series(fund, years)
|
|
143
|
+
if len(f) < 30:
|
|
144
|
+
continue
|
|
145
|
+
row = {"years": years, "windows": int(len(f)), "from": str(f.index[0].date()),
|
|
146
|
+
"median": float(f.median()), "mean": float(f.mean()), "min": float(f.min()), "max": float(f.max()),
|
|
147
|
+
"worst_start": str(f.idxmin().date()), "best_start": str(f.idxmax().date()),
|
|
148
|
+
"negative_pct": float((f < 0).mean() * 100),
|
|
149
|
+
"above_8_pct": float((f > 8).mean() * 100), "above_12_pct": float((f > 12).mean() * 100),
|
|
150
|
+
"bench_median": None, "beat_pct": None, "compared": 0}
|
|
151
|
+
if idx is not None:
|
|
152
|
+
b = _cagr_series(idx, years)
|
|
153
|
+
pair = pd.concat([f, b], axis=1, keys=["f", "b"]).dropna()
|
|
154
|
+
if len(pair) >= 30:
|
|
155
|
+
row.update(bench_median=float(pair["b"].median()), compared=int(len(pair)),
|
|
156
|
+
beat_pct=float((pair["f"] > pair["b"]).mean() * 100))
|
|
157
|
+
out.append(row)
|
|
158
|
+
# 3-year rolling CAGR (or the longest window there is) for a chart, weekly points
|
|
159
|
+
chart_years = next((y for y in (3, 1) if any(r["years"] == y for r in out)), None)
|
|
160
|
+
chart = []
|
|
161
|
+
if chart_years:
|
|
162
|
+
f = _cagr_series(fund, chart_years).resample("W").last().dropna()
|
|
163
|
+
b = _cagr_series(idx, chart_years).resample("W").last() if idx is not None else None
|
|
164
|
+
for day, v in f.items():
|
|
165
|
+
bv = b.get(day) if b is not None else None
|
|
166
|
+
chart.append({"date": str(day.date()), "fund": round(float(v), 2),
|
|
167
|
+
"bench": None if bv is None or pd.isna(bv) else round(float(bv), 2)})
|
|
168
|
+
return {"windows": out, "chart_years": chart_years, "chart": chart}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _returns(s: pd.Series, since: pd.Timestamp) -> pd.Series:
|
|
172
|
+
s = s.sort_index()
|
|
173
|
+
s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
|
|
174
|
+
s = s[~s.index.duplicated(keep="last")]
|
|
175
|
+
return s[s.index >= since].pct_change().dropna()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def risk(nav: pd.Series, bench: Optional[pd.Series] = None) -> Optional[dict]:
|
|
179
|
+
"""Last RISK_YEARS of daily returns (business days both series traded)."""
|
|
180
|
+
from ..portfolio.analytics import ratios, versus_benchmark
|
|
181
|
+
|
|
182
|
+
end = pd.DatetimeIndex(nav.index).tz_localize(None).max()
|
|
183
|
+
since = end - pd.DateOffset(years=RISK_YEARS)
|
|
184
|
+
fund = _returns(nav, since)
|
|
185
|
+
if len(fund) < 120:
|
|
186
|
+
return None
|
|
187
|
+
out = {"years": RISK_YEARS, "from": str(fund.index[0].date()), "risk_free": RISK_FREE * 100,
|
|
188
|
+
**ratios(fund, RISK_FREE), "beta": None, "alpha": None, "correlation": None,
|
|
189
|
+
"bench_annual_return": None, "bench_volatility": None, "bench_max_drawdown": None}
|
|
190
|
+
if bench is not None and len(bench):
|
|
191
|
+
b = _returns(bench, since)
|
|
192
|
+
pair = pd.concat([fund, b], axis=1, keys=["f", "b"]).dropna()
|
|
193
|
+
if len(pair) >= 120:
|
|
194
|
+
bvar = float(pair["b"].var())
|
|
195
|
+
beta = float(pair["f"].cov(pair["b"]) / bvar) if bvar else None
|
|
196
|
+
bstats = ratios(pair["b"], RISK_FREE)
|
|
197
|
+
fann = float((1 + pair["f"]).prod()) ** (TRADING_DAYS / len(pair)) - 1
|
|
198
|
+
alpha = (fann - RISK_FREE - beta * (bstats["annual_return"] - RISK_FREE)) if beta is not None else None
|
|
199
|
+
out.update(versus_benchmark(pair["f"], pair["b"]))
|
|
200
|
+
out.update(beta=finite(beta), alpha=finite(alpha), correlation=finite(pair["f"].corr(pair["b"])),
|
|
201
|
+
bench_annual_return=bstats["annual_return"], bench_volatility=bstats["volatility"],
|
|
202
|
+
bench_max_drawdown=bstats["max_drawdown"])
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _episodes(curve: pd.Series) -> list[dict]:
|
|
207
|
+
"""Every fall from a high, deepest first: peak, trough, depth, recovery."""
|
|
208
|
+
curve = curve.dropna()
|
|
209
|
+
peak = curve.cummax()
|
|
210
|
+
under = curve / peak - 1
|
|
211
|
+
out, start = [], None
|
|
212
|
+
for day, dd in under.items():
|
|
213
|
+
if dd < -1e-9 and start is None:
|
|
214
|
+
start = day
|
|
215
|
+
elif dd >= -1e-9 and start is not None: # back at the old high (allowing float noise)
|
|
216
|
+
out.append((start, day))
|
|
217
|
+
start = None
|
|
218
|
+
if start is not None:
|
|
219
|
+
out.append((start, None))
|
|
220
|
+
rows = []
|
|
221
|
+
for begin, back in out:
|
|
222
|
+
span = under[begin:back] if back is not None else under[begin:]
|
|
223
|
+
trough_day = span.idxmin()
|
|
224
|
+
depth = float(span.min())
|
|
225
|
+
if -depth < MIN_DRAWDOWN:
|
|
226
|
+
continue
|
|
227
|
+
peak_day = curve[:begin].index[-2] if len(curve[:begin]) > 1 else begin
|
|
228
|
+
rows.append({"peak": str(peak_day.date()), "trough": str(trough_day.date()),
|
|
229
|
+
"recovered": str(back.date()) if back is not None else None, "depth": depth * 100,
|
|
230
|
+
"days_down": int((trough_day - peak_day).days),
|
|
231
|
+
"days_to_recover": int((back - trough_day).days) if back is not None else None})
|
|
232
|
+
rows.sort(key=lambda r: r["depth"])
|
|
233
|
+
return rows[:TOP_DRAWDOWNS]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _window_move(s: pd.Series, start: str, end: str) -> Optional[float]:
|
|
237
|
+
"""Worst peak-to-trough fall (%) inside the window."""
|
|
238
|
+
w = s[(s.index >= start) & (s.index <= end)].dropna()
|
|
239
|
+
if len(w) < 10:
|
|
240
|
+
return None
|
|
241
|
+
return float((w / w.cummax() - 1).min() * 100)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def drawdowns(nav: pd.Series, bench: Optional[pd.Series] = None) -> dict:
|
|
245
|
+
fund = _daily(nav)
|
|
246
|
+
under = (fund / fund.cummax() - 1) * 100
|
|
247
|
+
idx = _daily(bench) if bench is not None and len(bench) else None
|
|
248
|
+
weekly = under.resample("W").min().dropna()
|
|
249
|
+
bweekly = None
|
|
250
|
+
if idx is not None:
|
|
251
|
+
bunder = (idx / idx.cummax() - 1) * 100
|
|
252
|
+
bweekly = bunder[bunder.index >= fund.index[0]].resample("W").min()
|
|
253
|
+
chart = [{"date": str(d.date()), "fund": round(float(v), 2),
|
|
254
|
+
"bench": None if bweekly is None or pd.isna(bweekly.get(d)) else round(float(bweekly.get(d)), 2)}
|
|
255
|
+
for d, v in weekly.items()]
|
|
256
|
+
crashes = []
|
|
257
|
+
for label, start, end in EPISODES:
|
|
258
|
+
f = _window_move(fund, start, end)
|
|
259
|
+
if f is None:
|
|
260
|
+
continue
|
|
261
|
+
crashes.append({"label": label, "from": start, "to": end, "fund": f,
|
|
262
|
+
"bench": _window_move(idx, start, end) if idx is not None else None})
|
|
263
|
+
return {"current": float(under.iloc[-1]), "max": float(under.min()), "episodes": _episodes(fund),
|
|
264
|
+
"crashes": crashes, "chart": chart}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _bench_history(ticker: str) -> Optional[pd.Series]:
|
|
268
|
+
from ..portfolio.analytics import _benchmark_long
|
|
269
|
+
|
|
270
|
+
return _benchmark_long(ticker)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def dividend_yield(ticker: str, fetch: BenchFetch = _bench_history) -> Optional[float]:
|
|
274
|
+
"""Yearly gap between an ETF's dividend-adjusted price and its price index
|
|
275
|
+
over the last YIELD_YEARS (or as long as both exist, after the ETF's first month)."""
|
|
276
|
+
etf_ticker = TR_PROXY.get(ticker)
|
|
277
|
+
if etf_ticker is None:
|
|
278
|
+
sister = TR_SISTER.get(ticker)
|
|
279
|
+
return dividend_yield(sister, fetch) if sister else None
|
|
280
|
+
index, etf = fetch(ticker), fetch(etf_ticker)
|
|
281
|
+
if index is None or etf is None or len(etf) < 260:
|
|
282
|
+
return None
|
|
283
|
+
pair = pd.concat([_daily(index), _daily(etf)], axis=1, keys=["i", "e"]).dropna()
|
|
284
|
+
pair = pair[pair.index >= max(pair.index[0] + pd.Timedelta(days=30),
|
|
285
|
+
pair.index[-1] - pd.DateOffset(years=YIELD_YEARS))]
|
|
286
|
+
if len(pair) < 365:
|
|
287
|
+
return None
|
|
288
|
+
years = (pair.index[-1] - pair.index[0]).days / 365.25
|
|
289
|
+
growth = (pair["e"].iloc[-1] / pair["e"].iloc[0]) / (pair["i"].iloc[-1] / pair["i"].iloc[0])
|
|
290
|
+
gap = growth ** (1 / years) - 1 # the rate total_return() compounds back in
|
|
291
|
+
return float(min(max(gap, 0.0), MAX_YIELD))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def total_return(index: pd.Series, yearly: float) -> pd.Series:
|
|
295
|
+
"""Price index with dividends credited at a steady yearly rate."""
|
|
296
|
+
idx = index.sort_index()
|
|
297
|
+
t = (pd.DatetimeIndex(idx.index) - pd.DatetimeIndex(idx.index)[0]).days / 365.25
|
|
298
|
+
return idx * np.power(1 + yearly, t)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _row(code: str) -> Optional[dict]:
|
|
302
|
+
from ..data.listings import get_store
|
|
303
|
+
|
|
304
|
+
return next((r for r in get_store().rows("india") if r.get("type") == "mf" and str(r["symbol"]) == code), None)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def is_payout(row: Optional[dict]) -> bool:
|
|
308
|
+
"""IDCW / dividend options: each payout lowers the NAV, so NAV returns understate them."""
|
|
309
|
+
text = f"{(row or {}).get('option') or ''} {(row or {}).get('name') or ''}".lower()
|
|
310
|
+
return any(k in text for k in ("idcw", "dividend option", "payout", "income distribution"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build(code: str, fetch: Optional[NavFetch] = None, bench_fetch: BenchFetch = _bench_history,
|
|
314
|
+
row: Optional[dict] = None, use_cache: bool = True) -> dict:
|
|
315
|
+
code = str(code)
|
|
316
|
+
if use_cache:
|
|
317
|
+
with _lock:
|
|
318
|
+
hit = _cache.get(code)
|
|
319
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
320
|
+
return hit[1]
|
|
321
|
+
if fetch is None:
|
|
322
|
+
from .fund_peers import nav as fetch
|
|
323
|
+
row = row if row is not None else _row(code)
|
|
324
|
+
nav = fetch(code)
|
|
325
|
+
if nav is None or len(nav) < 60:
|
|
326
|
+
return {"status": "no_history", "note": "Not enough NAV history for this fund yet."}
|
|
327
|
+
nav = clean_nav(nav)
|
|
328
|
+
bench_info = benchmark_for((row or {}).get("sector"), (row or {}).get("name", ""))
|
|
329
|
+
bench, yearly = None, None
|
|
330
|
+
if bench_info:
|
|
331
|
+
try:
|
|
332
|
+
price = bench_fetch(bench_info[0])
|
|
333
|
+
yearly = dividend_yield(bench_info[0], bench_fetch) if price is not None else None
|
|
334
|
+
bench = total_return(price, yearly or 0.0) if price is not None else None
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
logger.info("Benchmark %s unavailable: %s", bench_info[0], exc)
|
|
337
|
+
out = {
|
|
338
|
+
"status": "ok",
|
|
339
|
+
"code": code,
|
|
340
|
+
"category": (row or {}).get("sector"),
|
|
341
|
+
"nav_from": str(pd.Timestamp(nav.index.min()).date()),
|
|
342
|
+
"nav_to": str(pd.Timestamp(nav.index.max()).date()),
|
|
343
|
+
"benchmark": {"symbol": bench_info[0], "name": bench_info[1], "available": bench is not None,
|
|
344
|
+
"dividend_yield": yearly * 100 if yearly is not None else None}
|
|
345
|
+
if bench_info else None,
|
|
346
|
+
"rolling": rolling(nav, bench),
|
|
347
|
+
"payout_option": is_payout(row),
|
|
348
|
+
"risk": risk(nav, bench),
|
|
349
|
+
"drawdowns": drawdowns(nav, bench),
|
|
350
|
+
"note": "NAV history from official fund data; index history from the market data feed, with dividends added back at the "
|
|
351
|
+
"rate an ETF on the index earned over it, so the comparison is total return against total "
|
|
352
|
+
"return, as SEBI asks. Past returns don't predict future ones.",
|
|
353
|
+
}
|
|
354
|
+
_clean(out)
|
|
355
|
+
if use_cache:
|
|
356
|
+
with _lock:
|
|
357
|
+
_cache[code] = (time.time(), out)
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _clean(obj):
|
|
362
|
+
"""NaN / inf -> None everywhere (JSON has no NaN)."""
|
|
363
|
+
if isinstance(obj, dict):
|
|
364
|
+
for k, v in obj.items():
|
|
365
|
+
obj[k] = _clean(v)
|
|
366
|
+
elif isinstance(obj, list):
|
|
367
|
+
for i, v in enumerate(obj):
|
|
368
|
+
obj[i] = _clean(v)
|
|
369
|
+
elif isinstance(obj, float) and not math.isfinite(obj):
|
|
370
|
+
return None
|
|
371
|
+
return obj
|