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,246 @@
1
+ """Full Shariah screen for one stock: business activity + financial ratios.
2
+
3
+ Methodology (the common ground of the major Islamic index providers):
4
+
5
+ * Business screen - the core business must not be interest-based finance,
6
+ insurance, alcohol, tobacco, pork, gambling, adult entertainment or
7
+ weapons. Mixed businesses (hotels, media, REITs) are flagged doubtful.
8
+ * Financial screens - S&P / Dow Jones Islamic style, the basis of the
9
+ Nifty / BSE Shariah indices that Indian "Ethical" funds track:
10
+ interest-bearing debt < 33% of 36-month average market cap
11
+ cash + interest-bearing assets < 33% of 36-month average market cap
12
+ receivables + cash < 49% of 36-month average market cap
13
+ AAOIFI uses a stricter 30% for the first two; a stock between 30% and
14
+ 33% passes S&P but not AAOIFI and is reported as doubtful.
15
+ * Non-permissible income - interest income must stay under 5% of revenue.
16
+ That share of any dividend (or gain) should be given to charity
17
+ ("purification").
18
+
19
+ Market cap falls back to total assets (MSCI / FTSE style) when Yahoo has
20
+ no price history. Everything comes from Yahoo Finance and is best-effort:
21
+ this is a screening aid, not a fatwa.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import threading
28
+ import time
29
+ from typing import Optional
30
+
31
+ from marketmyna.data.shariah import DOUBTFUL, HALAL, HARAM, UNKNOWN, business_status, screen_row
32
+
33
+ from .financials import BALANCE_ROWS, _div, _extract, _num, _periods
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ LIMIT_DEBT = 33.0
38
+ LIMIT_CASH = 33.0
39
+ LIMIT_RECEIVABLES = 49.0
40
+ LIMIT_AAOIFI = 30.0
41
+ LIMIT_INTEREST_INCOME = 5.0
42
+
43
+ _INTEREST_INCOME_ROWS = [("interest_income", "Interest income", ["Interest Income Non Operating", "Interest Income"])]
44
+ _REVENUE_ROWS = [("revenue", "Total revenue", ["Total Revenue", "Operating Revenue"])]
45
+
46
+ _TTL = 6 * 3600
47
+ _cache: dict[tuple[str, str], tuple[float, dict]] = {}
48
+ _lock = threading.Lock()
49
+
50
+ VERDICT_LABELS = {
51
+ HALAL: "Ethical",
52
+ DOUBTFUL: "Doubtful",
53
+ HARAM: "Not ethical",
54
+ UNKNOWN: "Not enough data",
55
+ }
56
+
57
+
58
+ def _check(key: str, label: str, value: Optional[float], limit: float, basis: str, strict: Optional[float] = None) -> dict:
59
+ """One ratio test. status: pass | fail | borderline (passes S&P, fails AAOIFI) | unknown."""
60
+ if value is None:
61
+ status = "unknown"
62
+ elif value >= limit:
63
+ status = "fail"
64
+ elif strict is not None and value >= strict:
65
+ status = "borderline"
66
+ else:
67
+ status = "pass"
68
+ return {"key": key, "label": label, "value": value, "limit": limit, "strict_limit": strict, "basis": basis, "status": status}
69
+
70
+
71
+ def _avg_market_cap(ticker, info: dict) -> tuple[Optional[float], str]:
72
+ """36-month average market cap in the reporting currency.
73
+
74
+ Scales today's market cap by (average monthly close / last close), so
75
+ share-class and ADR ratios stay right, then converts from the trading
76
+ currency to the currency the statements are published in."""
77
+ mcap = _num(info.get("marketCap"))
78
+ if not mcap:
79
+ return None, ""
80
+ basis = "current market cap"
81
+ try:
82
+ hist = ticker.history(period="3y", interval="1mo")
83
+ closes = hist["Close"].dropna() if hist is not None and not hist.empty else None
84
+ if closes is not None and len(closes) >= 6 and closes.iloc[-1] > 0:
85
+ mcap = mcap * float(closes.mean()) / float(closes.iloc[-1])
86
+ basis = f"{len(closes)}-month average market cap"
87
+ except Exception as exc:
88
+ logger.info("Shariah: price history failed: %s", exc)
89
+
90
+ trade_ccy = info.get("currency")
91
+ fin_ccy = info.get("financialCurrency")
92
+ if trade_ccy and fin_ccy and trade_ccy != fin_ccy:
93
+ import yfinance as yf
94
+
95
+ from ..data import ecb_fx
96
+
97
+ pair = f"{trade_ccy}{fin_ccy}=X"
98
+ try:
99
+ fx = ecb_fx.guarded(lambda: {pair: yf.Ticker(pair).history(period="5d")["Close"].dropna()},
100
+ [pair], days=14)[pair]
101
+ mcap = mcap * float(fx.iloc[-1])
102
+ basis += f" (converted {trade_ccy}→{fin_ccy})"
103
+ except Exception as exc:
104
+ logger.info("Shariah: FX %s->%s failed: %s", trade_ccy, fin_ccy, exc)
105
+ return None, ""
106
+ return mcap, basis
107
+
108
+
109
+ def _latest(values: list[Optional[float]]) -> Optional[float]:
110
+ return next((v for v in values if v is not None), None)
111
+
112
+
113
+ def _pct(a: Optional[float], b: Optional[float]) -> Optional[float]:
114
+ r = _div(a, b)
115
+ return round(r * 100, 2) if r is not None else None
116
+
117
+
118
+ def build_shariah_profile(symbol: str, market: str) -> dict:
119
+ key = (symbol.upper(), market)
120
+ with _lock:
121
+ hit = _cache.get(key)
122
+ if hit and time.time() - hit[0] < _TTL:
123
+ return hit[1]
124
+ data = _build(symbol, market)
125
+ with _lock:
126
+ _cache[key] = (time.time(), data)
127
+ return data
128
+
129
+
130
+ def _build(symbol: str, market: str) -> dict:
131
+ import yfinance as yf
132
+
133
+ from .analysis_run import yahoo_symbol
134
+
135
+ if market == "crypto":
136
+ raise ValueError("The ethical screen does not cover crypto")
137
+
138
+ ticker = yf.Ticker(yahoo_symbol(symbol, market))
139
+ info: dict = {}
140
+ try:
141
+ info = ticker.info or {}
142
+ except Exception as exc:
143
+ logger.info("Shariah info for %s failed: %s", symbol, exc)
144
+ name = info.get("longName") or info.get("shortName") or symbol
145
+ base = {
146
+ "symbol": symbol,
147
+ "market": market,
148
+ "name": name,
149
+ "sector": info.get("sector"),
150
+ "industry": info.get("industry"),
151
+ "currency": info.get("financialCurrency") or info.get("currency"),
152
+ "source": "Company financial statements (market data feed)",
153
+ }
154
+
155
+ # Funds: the mandate decides, not balance-sheet ratios.
156
+ if info.get("quoteType") in ("ETF", "MUTUALFUND"):
157
+ status, reason = screen_row({"symbol": symbol, "name": name, "market": market, "type": "etf"})
158
+ return {
159
+ **base,
160
+ "kind": "fund",
161
+ "verdict": status,
162
+ "verdict_label": VERDICT_LABELS[status],
163
+ "business": {"status": status, "reason": reason, "flags": []},
164
+ "checks": [],
165
+ "purification_pct": None,
166
+ "reasons": [reason],
167
+ }
168
+
169
+ bal_df = ticker.balance_sheet
170
+ inc_df = ticker.income_stmt
171
+ periods = _periods(bal_df, inc_df)
172
+ if not info.get("sector") and not info.get("marketCap") and not periods:
173
+ # Yahoo throttles or has no coverage - fail rather than cache an empty screen.
174
+ raise RuntimeError(f"No financial data for {symbol} right now - try again shortly")
175
+
176
+ b_status, b_reason, flags = business_status(name, info.get("sector"), info.get("industry"), info.get("longBusinessSummary") or "")
177
+
178
+ bal = _extract(bal_df, BALANCE_ROWS, periods) if periods else {}
179
+ inc = _extract(inc_df, _INTEREST_INCOME_ROWS + _REVENUE_ROWS, periods) if periods else {}
180
+
181
+ debt = _latest(bal.get("total_debt", []))
182
+ cash = _latest(bal.get("cash", []))
183
+ receivables = _latest(bal.get("receivables", []))
184
+ assets = _latest(bal.get("total_assets", []))
185
+ revenue = _latest(inc.get("revenue", []))
186
+ interest_income = _latest(inc.get("interest_income", []))
187
+ if debt is None and assets is not None and bal_df is not None and not bal_df.empty and "Total Debt" not in bal_df.index:
188
+ debt = 0.0 # No debt line at all means no borrowings were reported.
189
+
190
+ mcap, basis = _avg_market_cap(ticker, info)
191
+ denom = mcap
192
+ if not denom:
193
+ denom, basis = assets, "total assets"
194
+
195
+ recv_cash = receivables + cash if receivables is not None and cash is not None else None
196
+ checks = [
197
+ _check("debt", "Interest-bearing debt", _pct(debt, denom), LIMIT_DEBT, basis, LIMIT_AAOIFI),
198
+ _check("cash", "Cash & interest-bearing securities", _pct(cash, denom), LIMIT_CASH, basis, LIMIT_AAOIFI),
199
+ _check("receivables", "Receivables + cash", _pct(recv_cash, denom), LIMIT_RECEIVABLES, basis),
200
+ _check("interest_income", "Interest income", _pct(interest_income, revenue), LIMIT_INTEREST_INCOME, "revenue"),
201
+ ]
202
+ if revenue and interest_income is None:
203
+ # Yahoo omits the line for many companies; treat as not reported rather than failing the stock.
204
+ checks[-1]["note"] = "Not reported separately - read the annual report's 'other income' note"
205
+
206
+ reasons: list[str] = []
207
+ if b_status == HARAM:
208
+ verdict = HARAM
209
+ reasons.append(b_reason)
210
+ else:
211
+ failed = [c for c in checks if c["status"] == "fail"]
212
+ borderline = [c for c in checks if c["status"] == "borderline"]
213
+ missing = [c for c in checks[:2] if c["status"] == "unknown"]
214
+ for c in failed:
215
+ reasons.append(f"{c['label']} is {c['value']:.1f}% of {c['basis']} (limit {c['limit']:.0f}%)")
216
+ for c in borderline:
217
+ reasons.append(f"{c['label']} is {c['value']:.1f}% - within the standard {c['limit']:.0f}% limit but above the stricter {c['strict_limit']:.0f}%")
218
+ if b_status in (DOUBTFUL, UNKNOWN):
219
+ reasons.append(b_reason)
220
+ if missing:
221
+ reasons.append("Missing balance-sheet data: " + ", ".join(c["label"].lower() for c in missing))
222
+ if failed:
223
+ verdict = HARAM
224
+ elif borderline or b_status == DOUBTFUL:
225
+ verdict = DOUBTFUL
226
+ elif missing or b_status == UNKNOWN:
227
+ verdict = UNKNOWN if len(missing) == 2 else DOUBTFUL
228
+ else:
229
+ verdict = HALAL
230
+ reasons.append(b_reason)
231
+ reasons.append("Debt, cash and receivables are all within the standard and the stricter limits")
232
+
233
+ # Purification only applies to a stock one may hold.
234
+ purification = _pct(interest_income, revenue) if verdict != HARAM else None
235
+ return {
236
+ **base,
237
+ "kind": "stock",
238
+ "verdict": verdict,
239
+ "verdict_label": VERDICT_LABELS[verdict],
240
+ "business": {"status": b_status, "reason": b_reason, "flags": flags},
241
+ "checks": checks,
242
+ "market_cap_basis": basis or None,
243
+ "period": str(periods[0])[:10] if periods else None,
244
+ "purification_pct": purification,
245
+ "reasons": reasons,
246
+ }
@@ -0,0 +1,148 @@
1
+ """SIP backtest on a fund's real NAV history, plus a step-up projection.
2
+
3
+ Answers three questions for one scheme: what would a monthly SIP over the last
4
+ N years be worth today, would a lumpsum of the same total have done better, and
5
+ does the SIP day of the month matter. Pure functions over a NAV series so they
6
+ test offline; the step-up maths reuses SIPOptimizer (marketmyna/analysis/sip_optimizer.py).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import date
12
+
13
+ import pandas as pd
14
+
15
+ SIP_DAYS = (1, 5, 10, 15, 20, 25)
16
+
17
+
18
+ def xirr(flows: list[tuple[date, float]]) -> float | None:
19
+ """Annualised internal rate of return for dated cash flows (bisection)."""
20
+ if len(flows) < 2 or not any(v > 0 for _, v in flows) or not any(v < 0 for _, v in flows):
21
+ return None
22
+ t0 = flows[0][0]
23
+
24
+ def npv(rate: float) -> float:
25
+ return sum(v / (1 + rate) ** ((d - t0).days / 365.0) for d, v in flows)
26
+
27
+ lo, hi = -0.99, 10.0
28
+ if npv(lo) * npv(hi) > 0:
29
+ return None
30
+ for _ in range(200):
31
+ mid = (lo + hi) / 2
32
+ if npv(lo) * npv(mid) <= 0:
33
+ hi = mid
34
+ else:
35
+ lo = mid
36
+ return (lo + hi) / 2
37
+
38
+
39
+ def _buy_dates(nav: pd.Series, start: pd.Timestamp, day: int) -> list[pd.Timestamp]:
40
+ """First NAV date on or after `day` of each month from `start` (next trading day)."""
41
+ out: list[pd.Timestamp] = []
42
+ month = pd.Timestamp(start.year, start.month, 1)
43
+ end = nav.index[-1]
44
+ while month <= end:
45
+ target = month + pd.Timedelta(days=day - 1)
46
+ pos = nav.index.searchsorted(target)
47
+ if pos < len(nav) and nav.index[pos] >= start and (nav.index[pos].year, nav.index[pos].month) == (target.year, target.month):
48
+ out.append(nav.index[pos])
49
+ month += pd.offsets.MonthBegin(1)
50
+ return out
51
+
52
+
53
+ def simulate(nav: pd.Series, monthly: float, start: pd.Timestamp, day: int) -> dict | None:
54
+ """Monthly SIP of `monthly` from `start` on `day`, valued at the last NAV."""
55
+ dates = _buy_dates(nav, start, day)
56
+ if len(dates) < 2:
57
+ return None
58
+ units = sum(monthly / float(nav[d]) for d in dates)
59
+ invested = monthly * len(dates)
60
+ last_date, last_nav = nav.index[-1], float(nav.iloc[-1])
61
+ value = units * last_nav
62
+ flows = [(d.date(), -monthly) for d in dates] + [(last_date.date(), value)]
63
+ return {
64
+ "day": day,
65
+ "installments": len(dates),
66
+ "invested": invested,
67
+ "units": units,
68
+ "avg_cost": invested / units,
69
+ "value": value,
70
+ "gain_pct": (value / invested - 1) * 100,
71
+ "xirr_pct": _pct(xirr(flows)),
72
+ }
73
+
74
+
75
+ def backtest(nav: pd.Series, monthly: float, years: int, day: int) -> dict:
76
+ """SIP vs lumpsum vs SIP day, over the last `years` of NAV history."""
77
+ nav = nav.dropna().sort_index()
78
+ if len(nav) < 30:
79
+ return {"error": "Not enough NAV history"}
80
+ end = nav.index[-1]
81
+ start = max(end - pd.DateOffset(years=years), nav.index[0])
82
+ note = None
83
+ if start > end - pd.DateOffset(years=years) + pd.Timedelta(days=31):
84
+ note = f"Fund history starts {nav.index[0].date()}, so the backtest is shorter than {years} years."
85
+
86
+ sip = simulate(nav, monthly, start, day)
87
+ if sip is None:
88
+ return {"error": "Not enough NAV history"}
89
+
90
+ first = _buy_dates(nav, start, day)[0]
91
+ lump_nav = float(nav[first])
92
+ lump_value = sip["invested"] / lump_nav * float(nav.iloc[-1])
93
+ span = (end - first).days / 365.0
94
+ lump = {
95
+ "date": str(first.date()),
96
+ "invested": sip["invested"],
97
+ "value": lump_value,
98
+ "gain_pct": (lump_value / sip["invested"] - 1) * 100,
99
+ "cagr_pct": ((lump_value / sip["invested"]) ** (1 / span) - 1) * 100 if span > 0 else None,
100
+ }
101
+
102
+ # Rank days by XIRR, not end value: a later day can buy one instalment fewer
103
+ by_day = [r for d in SIP_DAYS if (r := simulate(nav, monthly, start, d))]
104
+ ranked = [r for r in by_day if r["xirr_pct"] is not None]
105
+ spread = None
106
+ if ranked:
107
+ best = max(ranked, key=lambda r: r["xirr_pct"])
108
+ worst = min(ranked, key=lambda r: r["xirr_pct"])
109
+ spread = {"best_day": best["day"], "worst_day": worst["day"],
110
+ "diff_pts": best["xirr_pct"] - worst["xirr_pct"]}
111
+
112
+ return {
113
+ "start": str(start.date()),
114
+ "end": str(end.date()),
115
+ "monthly": monthly,
116
+ "sip": sip,
117
+ "lumpsum": lump,
118
+ "winner": "sip" if sip["value"] >= lump_value else "lumpsum",
119
+ "by_day": by_day,
120
+ "day_spread": spread,
121
+ "note": note,
122
+ }
123
+
124
+
125
+ def step_up(monthly: float, step_pct: float, years: int, expected_pct: float) -> dict:
126
+ """Projected corpus for a SIP that rises `step_pct` each year, vs a flat SIP."""
127
+ from marketmyna.analysis.sip_optimizer import SIPOptimizer
128
+
129
+ opt = SIPOptimizer(data_fetcher=object()) # projections need no data
130
+ stepped = opt.calculate_step_up_sip(monthly, step_pct, years, expected_pct)
131
+ flat = opt.calculate_step_up_sip(monthly, 0, years, expected_pct)
132
+ return {
133
+ "monthly": monthly,
134
+ "step_pct": step_pct,
135
+ "years": years,
136
+ "expected_pct": expected_pct,
137
+ "invested": stepped.total_invested,
138
+ "corpus": stepped.projected_corpus,
139
+ "flat_invested": flat.total_invested,
140
+ "flat_corpus": flat.projected_corpus,
141
+ "yearly": [{"year": y["year"], "monthly_sip": y["monthly_sip"], "corpus": y["year_end_corpus"],
142
+ "flat_corpus": f["year_end_corpus"]}
143
+ for y, f in zip(stepped.yearly_breakdown, flat.yearly_breakdown)],
144
+ }
145
+
146
+
147
+ def _pct(v: float | None) -> float | None:
148
+ return None if v is None else v * 100