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,362 @@
1
+ """Historically best SIP day for every Indian mutual fund and every listed ETF.
2
+
3
+ For one price series, replays a monthly SIP on each day 1-28 of the month
4
+ over the last 3, 5 and 10 years and ranks the days by XIRR (same rules as
5
+ services/sip.py: buy on the first trading day on or after the chosen day,
6
+ value everything at the last price). All 28 days are solved together with
7
+ numpy, so a fund costs a few milliseconds once its history is downloaded.
8
+
9
+ The edge is usually small and often down to chance, so each result carries
10
+ a label rather than a recommendation:
11
+ - "consistent": the best day of every window lands within a few days of
12
+ the others (needs 5+ years of history, i.e. two windows or more).
13
+ - "no_edge": the windows disagree; any day is as good as another.
14
+ - "short": only 3-5 years of history, too short to check consistency.
15
+ - "negligible": best and worst day differ by under MIN_GAP points a year
16
+ over the longest window (typical of debt funds, whose smooth NAV makes a
17
+ month-end day "win" every time by a meaningless margin).
18
+
19
+ A background loop (refresh_loop) keeps a per-market summary on disk under
20
+ ~/.marketmyna/sip_days/<market>.json, refreshing entries older than a week:
21
+ Indian schemes from mfapi.in NAVs, ETFs (India, US, Hong Kong) from Yahoo
22
+ daily closes adjusted for dividends. Past behaviour, not advice.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import threading
30
+ import time
31
+ from concurrent.futures import ThreadPoolExecutor
32
+ from datetime import date, datetime
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ import numpy as np
37
+ import pandas as pd
38
+
39
+ from marketmyna.core.home import app_home
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ DAYS = np.arange(1, 29)
44
+ WINDOWS = (3, 5, 10) # years
45
+ NEAR_DAYS = 3 # best days this close count as the same answer
46
+ MIN_GAP = 0.10 # points a year; below this the day does not matter
47
+ MAX_AGE_DAYS = 7
48
+ ETF_MARKETS = ("india", "us", "hong_kong")
49
+ STALE_NAV_DAYS = 30 # a scheme with no NAV for a month is wound up / merged
50
+
51
+
52
+ # ------------------------------------------------------------------ maths
53
+
54
+ def _buy_positions(index: pd.DatetimeIndex, start: pd.Timestamp) -> tuple[np.ndarray, np.ndarray]:
55
+ """Index positions of each day's monthly buys -> (pos[28, M], valid[28, M])."""
56
+ end = index[-1]
57
+ months = pd.date_range(pd.Timestamp(start.year, start.month, 1), end, freq="MS")
58
+ targets = months.values[None, :] + (DAYS[:, None] - 1).astype("timedelta64[D]")
59
+ pos = index.values.searchsorted(targets.ravel()).reshape(targets.shape)
60
+ inside = pos < len(index)
61
+ safe = np.minimum(pos, len(index) - 1)
62
+ bought = index.values[safe]
63
+ same_month = bought.astype("datetime64[M]") == targets.astype("datetime64[M]")
64
+ valid = inside & same_month & (bought >= np.datetime64(start))
65
+ return safe, valid
66
+
67
+
68
+ def day_xirrs(nav: pd.Series, start: pd.Timestamp) -> np.ndarray:
69
+ """XIRR (fraction a year) of a monthly SIP from `start` for days 1..28;
70
+ NaN where a day has fewer than two instalments or no solution."""
71
+ index = pd.DatetimeIndex(nav.index)
72
+ prices = nav.to_numpy(dtype=float)
73
+ pos, valid = _buy_positions(index, start)
74
+ count = valid.sum(axis=1)
75
+ units = np.where(valid, 1.0 / prices[pos], 0.0).sum(axis=1)
76
+ value = units * prices[-1]
77
+
78
+ days_of = index.values[pos].astype("datetime64[D]").astype(np.int64)
79
+ first = np.where(valid, days_of, np.iinfo(np.int64).max).min(axis=1)
80
+ t = np.where(valid, (days_of - first[:, None]) / 365.0, 0.0)
81
+ t_end = (np.int64(index.values[-1].astype("datetime64[D]").astype(np.int64)) - first) / 365.0
82
+
83
+ def npv(rate: np.ndarray) -> np.ndarray:
84
+ base = (1.0 + rate)[:, None]
85
+ outflow = np.where(valid, base ** (-t), 0.0).sum(axis=1)
86
+ return value * (1.0 + rate) ** (-t_end) - outflow
87
+
88
+ lo = np.full(len(DAYS), -0.99)
89
+ hi = np.full(len(DAYS), 10.0)
90
+ ok = (count >= 2) & (npv(lo) * npv(hi) <= 0)
91
+ for _ in range(80):
92
+ mid = (lo + hi) / 2
93
+ left = npv(lo) * npv(mid) <= 0
94
+ hi = np.where(left, mid, hi)
95
+ lo = np.where(left, lo, mid)
96
+ return np.where(ok, (lo + hi) / 2, np.nan)
97
+
98
+
99
+ def clean(nav: pd.Series) -> pd.Series:
100
+ """Sorted, positive, de-duplicated prices without one-off bad prints.
101
+
102
+ Yahoo sometimes keeps a day or two at a tenth of the real price around a
103
+ split (NIFTYBEES, Dec 2019); a price more than 40% away from the median of
104
+ the surrounding 11 days is dropped. Real crashes move far less in a day.
105
+ """
106
+ nav = nav.dropna()
107
+ nav = nav[nav > 0].sort_index()
108
+ nav = nav[~nav.index.duplicated(keep="last")]
109
+ if len(nav) < 11:
110
+ return nav
111
+ ratio = nav / nav.rolling(11, center=True, min_periods=3).median()
112
+ return nav[(ratio < 1.4) & (ratio > 1 / 1.4)]
113
+
114
+
115
+ def _near(a: int, b: int) -> bool:
116
+ gap = abs(a - b)
117
+ return min(gap, 30 - gap) <= NEAR_DAYS # day 28 sits next to next month's 1st
118
+
119
+
120
+ def label(windows: dict) -> str:
121
+ """consistent / no_edge / short / negligible for a {"3y": {...}, ...} map."""
122
+ longest = windows[list(windows)[-1]]
123
+ bests = [w["best"] for w in windows.values()]
124
+ if longest["spread"] < MIN_GAP:
125
+ return "negligible"
126
+ if len(bests) < 2:
127
+ return "short"
128
+ if all(_near(a, b) for a in bests for b in bests):
129
+ return "consistent"
130
+ return "no_edge"
131
+
132
+
133
+ def analyze(nav: pd.Series) -> Optional[dict]:
134
+ """Best / worst SIP day per window plus a consistency label, or None when
135
+ there are under 3 years of prices."""
136
+ nav = clean(nav)
137
+ if len(nav) < 30:
138
+ return None
139
+ end = nav.index[-1]
140
+ windows = {}
141
+ for years in WINDOWS:
142
+ start = end - pd.DateOffset(years=years)
143
+ if nav.index[0] > start + pd.Timedelta(days=31):
144
+ continue # not enough history for a full window
145
+ x = day_xirrs(nav, start)
146
+ if np.isnan(x).all():
147
+ continue
148
+ best, worst = int(np.nanargmax(x)), int(np.nanargmin(x))
149
+ windows[f"{years}y"] = {
150
+ "best": int(DAYS[best]),
151
+ "worst": int(DAYS[worst]),
152
+ "best_xirr": round(float(x[best]) * 100, 2),
153
+ "spread": round(float(x[best] - x[worst]) * 100, 2),
154
+ }
155
+ if not windows:
156
+ return None
157
+ longest = windows[list(windows)[-1]]
158
+ return {
159
+ "as_of": str(end.date()),
160
+ "best_day": longest["best"],
161
+ "spread": longest["spread"],
162
+ "edge": label(windows),
163
+ "windows": windows,
164
+ }
165
+
166
+
167
+ def curve(nav: pd.Series, years: int) -> list[dict]:
168
+ """XIRR % for each day 1..28 over one window (the fund page's chart)."""
169
+ nav = clean(nav)
170
+ if len(nav) < 30:
171
+ return []
172
+ start = max(nav.index[-1] - pd.DateOffset(years=years), nav.index[0])
173
+ x = day_xirrs(nav, start)
174
+ return [{"day": int(d), "xirr_pct": None if np.isnan(v) else round(float(v) * 100, 3)} for d, v in zip(DAYS, x)]
175
+
176
+
177
+ # ------------------------------------------------------------ storage
178
+
179
+ _lock = threading.Lock()
180
+ _memory: dict[str, dict] = {}
181
+
182
+
183
+ def _root() -> Path:
184
+ p = app_home() / "sip_days"
185
+ p.mkdir(parents=True, exist_ok=True)
186
+ return p
187
+
188
+
189
+ def load(market: str) -> dict:
190
+ """symbol -> summary for one market (cached in memory)."""
191
+ with _lock:
192
+ if market in _memory:
193
+ return _memory[market]
194
+ f = _root() / f"{market}.json"
195
+ try:
196
+ data = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {}
197
+ except (OSError, ValueError) as exc:
198
+ logger.warning("SIP days %s unreadable: %s", market, exc)
199
+ data = {}
200
+ for entry in data.values(): # labels follow the current rules, no refetch needed
201
+ if entry.get("windows"):
202
+ entry["edge"] = label(entry["windows"])
203
+ with _lock:
204
+ _memory[market] = data
205
+ return data
206
+
207
+
208
+ def _save(market: str, data: dict) -> None:
209
+ f = _root() / f"{market}.json"
210
+ tmp = f.with_suffix(".tmp")
211
+ tmp.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
212
+ tmp.replace(f)
213
+ with _lock:
214
+ _memory[market] = data
215
+
216
+
217
+ def annotate(rows: list[dict], market: str) -> None:
218
+ """Adds sip_day / sip_edge / sip_spread to mutual fund and ETF rows in place."""
219
+ markets = ("india", *ETF_MARKETS) if market == "all" else (market,)
220
+ tables = {m: load(m) for m in set(markets)}
221
+ for row in rows:
222
+ if row.get("type") not in ("mf", "etf"):
223
+ continue
224
+ hit = tables.get(row.get("market"), {}).get(str(row.get("symbol")))
225
+ if hit and hit.get("best_day"):
226
+ row["sip_day"] = hit["best_day"]
227
+ row["sip_edge"] = hit["edge"]
228
+ row["sip_spread"] = hit["spread"]
229
+
230
+
231
+ # ------------------------------------------------------------ fetching
232
+
233
+ def mf_nav(code: str) -> Optional[pd.Series]:
234
+ from ..data.mutual_funds import MutualFundDataFetcher
235
+
236
+ df = MutualFundDataFetcher().get_historical_nav(int(code), days=100_000)
237
+ return None if df.empty else df["nav"].astype(float)
238
+
239
+
240
+ def _yahoo_closes(tickers: list[str]) -> dict[str, pd.Series]:
241
+ import yfinance as yf
242
+
243
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
244
+
245
+ start = (pd.Timestamp.today() - pd.DateOffset(years=max(WINDOWS)) - pd.Timedelta(days=45)).date()
246
+ with YF_DOWNLOAD_LOCK:
247
+ df = yf.download(tickers, start=str(start), interval="1d", progress=False, auto_adjust=True, threads=True)
248
+ if df is None or df.empty:
249
+ return {}
250
+ closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
251
+ out = {}
252
+ for t in tickers:
253
+ if t in closes:
254
+ s = closes[t].dropna()
255
+ if len(s):
256
+ idx = pd.DatetimeIndex(s.index)
257
+ s.index = (idx.tz_localize(None) if idx.tz is not None else idx).normalize()
258
+ out[t] = s
259
+ return out
260
+
261
+
262
+ def etf_prices(symbol: str, market: str) -> Optional[pd.Series]:
263
+ from .analysis_run import yahoo_symbol
264
+
265
+ t = yahoo_symbol(symbol, market)
266
+ return _yahoo_closes([t]).get(t)
267
+
268
+
269
+ def _fresh(entry: Optional[dict], today: date) -> bool:
270
+ if not entry:
271
+ return False
272
+ try:
273
+ checked = datetime.fromisoformat(entry["checked"]).date()
274
+ except (KeyError, ValueError):
275
+ return False
276
+ return (today - checked).days < MAX_AGE_DAYS
277
+
278
+
279
+ def _targets(market: str, rows: list[dict]) -> list[str]:
280
+ want = "mf" if market == "india_mf" else "etf"
281
+ return [str(r["symbol"]) for r in rows if r.get("type") == want and r.get("symbol")]
282
+
283
+
284
+ def _record(series: Optional[pd.Series], today: date) -> dict:
285
+ entry: dict = {"checked": today.isoformat()}
286
+ if series is None or series.empty:
287
+ entry["status"] = "no_data"
288
+ return entry
289
+ if (pd.Timestamp(today) - pd.DatetimeIndex(series.index).max()).days > STALE_NAV_DAYS:
290
+ entry["status"] = "inactive"
291
+ return entry
292
+ result = analyze(series)
293
+ if result is None:
294
+ entry["status"] = "too_new"
295
+ return entry
296
+ entry.update(result)
297
+ return entry
298
+
299
+
300
+ def refresh_mutual_funds(workers: int = 4, save_every: int = 200) -> int:
301
+ """Indian schemes (every plan and option). Returns how many were updated."""
302
+ from ..data.listings import get_store
303
+
304
+ today = date.today()
305
+ data = dict(load("india"))
306
+ codes = [c for c in _targets("india_mf", get_store().rows("india")) if not _fresh(data.get(c), today)]
307
+ done = 0
308
+
309
+ def one(code: str) -> tuple[str, dict]:
310
+ try:
311
+ return code, _record(mf_nav(code), today)
312
+ except Exception as exc: # one bad scheme must not stop the run
313
+ logger.debug("SIP days %s: %s", code, exc)
314
+ return code, {"checked": today.isoformat(), "status": "error"}
315
+
316
+ with ThreadPoolExecutor(max_workers=workers) as pool:
317
+ for code, entry in pool.map(one, codes):
318
+ data[code] = entry
319
+ done += 1
320
+ if done % save_every == 0:
321
+ _save("india", data)
322
+ if done:
323
+ _save("india", data)
324
+ return done
325
+
326
+
327
+ def refresh_etfs(batch: int = 40) -> int:
328
+ from ..data.listings import get_store
329
+ from .analysis_run import yahoo_symbol
330
+
331
+ today = date.today()
332
+ total = 0
333
+ for market in ETF_MARKETS:
334
+ data = dict(load(market))
335
+ symbols = [s for s in _targets(market, get_store().rows(market)) if not _fresh(data.get(s), today)]
336
+ for i in range(0, len(symbols), batch):
337
+ chunk = symbols[i : i + batch]
338
+ tickers = {yahoo_symbol(s, market): s for s in chunk}
339
+ try:
340
+ closes = _yahoo_closes(list(tickers))
341
+ except Exception as exc:
342
+ logger.warning("SIP days ETF batch (%s): %s", market, exc)
343
+ continue
344
+ for t, s in tickers.items():
345
+ data[s] = _record(closes.get(t), today)
346
+ total += len(chunk)
347
+ _save(market, data)
348
+ time.sleep(1) # be gentle with Yahoo
349
+ return total
350
+
351
+
352
+ def refresh_loop(first_delay: int = 300, every: int = 6 * 3600) -> None:
353
+ """Background thread: fill and then keep the summaries fresh."""
354
+ time.sleep(first_delay) # after start-up and the listing warm-up
355
+ while True:
356
+ try:
357
+ n = refresh_mutual_funds()
358
+ m = refresh_etfs()
359
+ logger.info("SIP days refreshed: %d funds, %d ETFs", n, m)
360
+ except Exception as exc: # never kill the thread
361
+ logger.warning("SIP days refresh: %s", exc)
362
+ time.sleep(every)