marketmyna 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|