marketmyna 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ }