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,194 @@
|
|
|
1
|
+
"""The DCF as an Excel workbook with live formulas, so the model can be
|
|
2
|
+
audited and changed outside MarketMyna.
|
|
3
|
+
|
|
4
|
+
Follows the model-builder conventions of anthropics/financial-services
|
|
5
|
+
(Apache-2.0): every calculation cell is a formula, never a typed number;
|
|
6
|
+
inputs are blue and labelled with their source or as an assumption; a check
|
|
7
|
+
row flags an impossible set-up; a sensitivity grid recomputes the value per
|
|
8
|
+
share for other discount and terminal rates (plain formulas, no Excel data
|
|
9
|
+
table, so LibreOffice and Google Sheets read it too).
|
|
10
|
+
|
|
11
|
+
Same two-stage model as services/dcf.py and frontend/src/lib/dcf.ts: free
|
|
12
|
+
cash flow grows at the chosen rate for 5 years, fades in a straight line to
|
|
13
|
+
the terminal rate by year 10, then a Gordon-growth terminal value.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
from datetime import date
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from openpyxl import Workbook
|
|
23
|
+
from openpyxl.styles import Alignment, Font, PatternFill
|
|
24
|
+
from openpyxl.utils import get_column_letter
|
|
25
|
+
|
|
26
|
+
BLUE = Font(color="1F4FBF") # inputs
|
|
27
|
+
BOLD = Font(bold=True)
|
|
28
|
+
HEAD = Font(bold=True, color="FFFFFF")
|
|
29
|
+
HEAD_FILL = PatternFill("solid", fgColor="2F3E57")
|
|
30
|
+
KEY_FILL = PatternFill("solid", fgColor="EAF1FB")
|
|
31
|
+
MONEY = '#,##0;[Red]-#,##0'
|
|
32
|
+
PER_SHARE = '#,##0.00;[Red]-#,##0.00'
|
|
33
|
+
PCT = '0.0%'
|
|
34
|
+
|
|
35
|
+
YEARS = 10
|
|
36
|
+
HIGH = 5
|
|
37
|
+
COLS = [get_column_letter(c) for c in range(3, 3 + YEARS)] # C..L = years 1..10
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _row(ws, r: int, label: str, value, fmt: Optional[str] = None, note: str = "", font=None):
|
|
41
|
+
ws.cell(r, 1, label)
|
|
42
|
+
c = ws.cell(r, 2, value)
|
|
43
|
+
if fmt:
|
|
44
|
+
c.number_format = fmt
|
|
45
|
+
if font:
|
|
46
|
+
c.font = font
|
|
47
|
+
if note:
|
|
48
|
+
ws.cell(r, 3, note).font = Font(italic=True, color="666666")
|
|
49
|
+
return c
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _grid_formula(w: str, t: str, high: str) -> str:
|
|
53
|
+
"""Value per share at discount w and terminal growth t: years 1-5 are as
|
|
54
|
+
projected; years 6-10 fade to *this* terminal rate (as the page's sliders
|
|
55
|
+
do), then the terminal value."""
|
|
56
|
+
pv = [f"SUMPRODUCT($C$16:${high}$16/(1+{w})^$C$14:${high}$14)"]
|
|
57
|
+
flow = f"${high}$16"
|
|
58
|
+
fade = YEARS - HIGH
|
|
59
|
+
for k in range(1, fade + 1):
|
|
60
|
+
flow = f"{flow}*(1+$B$7+({t}-$B$7)*{k}/{fade})"
|
|
61
|
+
pv.append(f"{flow}/(1+{w})^{HIGH + k}")
|
|
62
|
+
pv.append(f"{flow}*(1+{t})/({w}-{t})/(1+{w})^{YEARS}")
|
|
63
|
+
return f"=IF({w}>{t},({'+'.join(pv)}+$B$10)/$B$11,\"n/a\")"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build(data: dict, growth: Optional[float] = None, discount: Optional[float] = None,
|
|
67
|
+
terminal: Optional[float] = None) -> bytes:
|
|
68
|
+
"""data: services/dcf.build() output with a "dcf" block. Rates default to the page's defaults."""
|
|
69
|
+
d = data.get("dcf")
|
|
70
|
+
if not d:
|
|
71
|
+
raise ValueError(data.get("note") or "No DCF for this stock")
|
|
72
|
+
dflt = d["defaults"]
|
|
73
|
+
growth = dflt["growth"] if growth is None else growth
|
|
74
|
+
discount = dflt["discount"] if discount is None else discount
|
|
75
|
+
terminal = dflt["terminal"] if terminal is None else terminal
|
|
76
|
+
ccy = data.get("currency") or ""
|
|
77
|
+
|
|
78
|
+
wb = Workbook()
|
|
79
|
+
ws = wb.active
|
|
80
|
+
ws.title = "DCF"
|
|
81
|
+
ws.column_dimensions["A"].width = 34
|
|
82
|
+
ws.column_dimensions["B"].width = 20
|
|
83
|
+
for col in COLS:
|
|
84
|
+
ws.column_dimensions[col].width = 15
|
|
85
|
+
|
|
86
|
+
ws["A1"] = f"{data.get('name') or data['symbol']} ({data['symbol']}) - discounted cash flow"
|
|
87
|
+
ws["A1"].font = Font(bold=True, size=14)
|
|
88
|
+
ws["A2"] = (f"Built by MarketMyna on {date.today().isoformat()} from {data.get('source', 'company filings')}. "
|
|
89
|
+
f"Amounts in {ccy}. Blue cells are inputs: change them and everything recalculates.")
|
|
90
|
+
ws["A3"] = "A model, not a forecast or a recommendation: the result is only as good as the assumptions."
|
|
91
|
+
ws["A3"].font = Font(italic=True, color="666666")
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------- inputs (rows 5-11)
|
|
94
|
+
ws["A5"] = "Inputs"
|
|
95
|
+
ws["A5"].font = BOLD
|
|
96
|
+
conv = f", converted from {d['converted_from']}" if d.get("converted_from") else ""
|
|
97
|
+
_row(ws, 6, f"Base free cash flow ({ccy})", d["base_fcf"], MONEY,
|
|
98
|
+
f"Operating cash flow minus capex and stock-based compensation, {d.get('base_label', 'latest year')}{conv}", BLUE)
|
|
99
|
+
_row(ws, 7, f"Growth a year, years 1-{HIGH}", growth, PCT,
|
|
100
|
+
"[ASSUMPTION] page default = recent revenue growth, capped 0-15%", BLUE)
|
|
101
|
+
_row(ws, 8, "Discount rate", discount, PCT, "[ASSUMPTION] the return you require a year", BLUE)
|
|
102
|
+
_row(ws, 9, "Terminal growth", terminal, PCT, "[ASSUMPTION] growth a year after year 10, for ever", BLUE)
|
|
103
|
+
_row(ws, 10, f"Net cash: cash and investments minus debt ({ccy})", d["net_cash"], MONEY,
|
|
104
|
+
"Latest balance sheet: cash, short-term and long-term investments, minus total debt", BLUE)
|
|
105
|
+
_row(ws, 11, "Shares outstanding", d["shares"], '#,##0', "Market data feed", BLUE)
|
|
106
|
+
_row(ws, 12, f"Share price ({ccy})", data.get("price"), PER_SHARE, "Market data feed, when the model was built", BLUE)
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------- projection (rows 14-18)
|
|
109
|
+
ws["A14"] = "Year"
|
|
110
|
+
ws["A15"] = "Growth"
|
|
111
|
+
ws["A16"] = f"Free cash flow ({ccy})"
|
|
112
|
+
ws["A17"] = "Discount factor"
|
|
113
|
+
ws["A18"] = f"Present value ({ccy})"
|
|
114
|
+
for c in ("A14", "A15", "A16", "A17", "A18"):
|
|
115
|
+
ws[c].font = BOLD
|
|
116
|
+
ws["A14"].font, ws["A14"].fill = HEAD, HEAD_FILL
|
|
117
|
+
for i, col in enumerate(COLS, start=1):
|
|
118
|
+
head = ws[f"{col}14"]
|
|
119
|
+
head.value, head.font, head.fill = i, HEAD, HEAD_FILL
|
|
120
|
+
head.alignment = Alignment(horizontal="center")
|
|
121
|
+
if i <= HIGH:
|
|
122
|
+
ws[f"{col}15"] = "=$B$7"
|
|
123
|
+
else: # straight-line fade to the terminal rate by year 10
|
|
124
|
+
ws[f"{col}15"] = f"=$B$7+($B$9-$B$7)*({col}$14-{HIGH})/({YEARS}-{HIGH})"
|
|
125
|
+
prev = "$B$6" if i == 1 else f"{COLS[i - 2]}16"
|
|
126
|
+
ws[f"{col}16"] = f"={prev}*(1+{col}15)"
|
|
127
|
+
ws[f"{col}17"] = f"=1/(1+$B$8)^{col}$14"
|
|
128
|
+
ws[f"{col}18"] = f"={col}16*{col}17"
|
|
129
|
+
ws[f"{col}15"].number_format = PCT
|
|
130
|
+
ws[f"{col}16"].number_format = MONEY
|
|
131
|
+
ws[f"{col}17"].number_format = '0.0000'
|
|
132
|
+
ws[f"{col}18"].number_format = MONEY
|
|
133
|
+
last = COLS[-1]
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------- valuation (rows 20-28)
|
|
136
|
+
ws["A20"] = "Valuation"
|
|
137
|
+
ws["A20"].font = BOLD
|
|
138
|
+
_row(ws, 21, "Present value, years 1-10", f"=SUM(C18:{last}18)", MONEY)
|
|
139
|
+
_row(ws, 22, "Terminal value at year 10", f"={last}16*(1+$B$9)/($B$8-$B$9)", MONEY, "Gordon growth")
|
|
140
|
+
_row(ws, 23, "Present value of the terminal value", f"=B22*{last}17", MONEY)
|
|
141
|
+
_row(ws, 24, "Enterprise value", "=B21+B23", MONEY)
|
|
142
|
+
_row(ws, 25, "Equity value (+ net cash)", "=B24+B10", MONEY)
|
|
143
|
+
v = _row(ws, 26, f"Value per share ({ccy})", "=B25/B11", PER_SHARE, font=BOLD)
|
|
144
|
+
v.fill = KEY_FILL
|
|
145
|
+
_row(ws, 27, "Price against the value", "=IF(B26>0,B12/B26-1,\"n/a\")", PCT,
|
|
146
|
+
"Above 0 = the price is higher than this value")
|
|
147
|
+
_row(ws, 28, "Terminal value share of the total", "=B23/(B21+B23)", PCT,
|
|
148
|
+
"Above ~75%: the answer rests mostly on the far future")
|
|
149
|
+
_row(ws, 29, "Check", "=IF($B$8>$B$9,\"OK\",\"Discount rate must be above terminal growth\")")
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------- sensitivity (rows 31-38)
|
|
152
|
+
ws["A31"] = f"Value per share ({ccy}): discount rate (rows) x terminal growth (columns)"
|
|
153
|
+
ws["A31"].font = BOLD
|
|
154
|
+
steps = (-0.01, -0.005, 0.0, 0.005, 0.01)
|
|
155
|
+
ws["B32"] = "Disc. \\ term."
|
|
156
|
+
ws["B32"].font = HEAD
|
|
157
|
+
ws["B32"].fill = HEAD_FILL
|
|
158
|
+
tcols = COLS[:len(steps)]
|
|
159
|
+
for col, step in zip(tcols, steps):
|
|
160
|
+
c = ws[f"{col}32"]
|
|
161
|
+
c.value = f"=$B$9+{step}" if step else "=$B$9"
|
|
162
|
+
c.number_format, c.font, c.fill = PCT, HEAD, HEAD_FILL
|
|
163
|
+
high = COLS[HIGH - 1] # year 5: the last cash flow the rates don't change
|
|
164
|
+
for r, step in enumerate((-0.02, -0.01, 0.0, 0.01, 0.02), start=33):
|
|
165
|
+
w = ws[f"B{r}"]
|
|
166
|
+
w.value = f"=$B$8+{step}" if step else "=$B$8"
|
|
167
|
+
w.number_format, w.font, w.fill = PCT, HEAD, HEAD_FILL
|
|
168
|
+
for col in tcols:
|
|
169
|
+
cell = ws[f"{col}{r}"]
|
|
170
|
+
cell.value = _grid_formula(f"$B{r}", f"{col}$32", high)
|
|
171
|
+
cell.number_format = PER_SHARE
|
|
172
|
+
if step == 0 and col == tcols[2]:
|
|
173
|
+
cell.fill, cell.font = KEY_FILL, BOLD
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------- history sheet
|
|
176
|
+
hs = wb.create_sheet("History")
|
|
177
|
+
hs.column_dimensions["A"].width = 16
|
|
178
|
+
hs.column_dimensions["B"].width = 22
|
|
179
|
+
hs["A1"] = "Year ending"
|
|
180
|
+
hs["B1"] = f"Free cash flow ({ccy})"
|
|
181
|
+
for c in ("A1", "B1"):
|
|
182
|
+
hs[c].font, hs[c].fill = HEAD, HEAD_FILL
|
|
183
|
+
for i, (period, value) in enumerate(zip(data.get("periods") or [], d.get("fcf_history") or []), start=2):
|
|
184
|
+
hs.cell(i, 1, period)
|
|
185
|
+
cell = hs.cell(i, 2, value)
|
|
186
|
+
cell.number_format, cell.font = MONEY, BLUE
|
|
187
|
+
hs.cell(len(d.get("fcf_history") or []) + 3, 1,
|
|
188
|
+
f"Source: {data.get('source', 'company filings')}. Operating cash flow minus capital expenditure "
|
|
189
|
+
"and stock-based compensation.")
|
|
190
|
+
|
|
191
|
+
ws.freeze_panes = "B5"
|
|
192
|
+
buf = io.BytesIO()
|
|
193
|
+
wb.save(buf)
|
|
194
|
+
return buf.getvalue()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Delivery % for NSE shares: of the shares traded in a session, how many
|
|
2
|
+
were taken for delivery (kept) rather than squared off intraday.
|
|
3
|
+
|
|
4
|
+
The exchange publishes one file per session after the close
|
|
5
|
+
(sec_bhavdata_full_DDMMYYYY.csv, ~400 KB, every symbol). It is fetched in
|
|
6
|
+
the background at most once per CHECK_SECONDS and saved to disk, so a page
|
|
7
|
+
never waits on the exchange and a restart doesn't fetch it again.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from zoneinfo import ZoneInfo
|
|
23
|
+
|
|
24
|
+
from marketmyna.core.home import app_home
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
URL = "https://nsearchives.nseindia.com/products/content/sec_bhavdata_full_{day:%d%m%Y}.csv"
|
|
29
|
+
CHECK_SECONDS = 30 * 60 # look for a newer session file at most this often
|
|
30
|
+
DAYS_BACK = 7 # weekends + holidays
|
|
31
|
+
IST = ZoneInfo("Asia/Kolkata")
|
|
32
|
+
|
|
33
|
+
_lock = threading.Lock()
|
|
34
|
+
_state: dict = {"date": None, "rows": {}, "checked": 0.0, "busy": False}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cache_file() -> Path:
|
|
38
|
+
base = Path(os.environ.get("DELIVERY_CACHE_DIR") or app_home() / "cache")
|
|
39
|
+
return base / "nse_delivery.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _num(text: str) -> Optional[float]:
|
|
43
|
+
try:
|
|
44
|
+
return float(text.strip().replace(",", ""))
|
|
45
|
+
except (AttributeError, ValueError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(text: str) -> dict[str, dict]:
|
|
50
|
+
"""Symbol -> session figures, normal (EQ / BE) series only."""
|
|
51
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
52
|
+
reader.fieldnames = [f.strip() for f in reader.fieldnames or []]
|
|
53
|
+
out: dict[str, dict] = {}
|
|
54
|
+
for r in reader:
|
|
55
|
+
r = {k: (v or "").strip() for k, v in r.items() if k}
|
|
56
|
+
sym, series = r.get("SYMBOL", "").upper(), r.get("SERIES", "")
|
|
57
|
+
if not sym or series not in ("EQ", "BE", "BZ", "SM", "ST"):
|
|
58
|
+
continue
|
|
59
|
+
if sym in out and series != "EQ":
|
|
60
|
+
continue
|
|
61
|
+
out[sym] = {
|
|
62
|
+
"traded": _num(r.get("TTL_TRD_QNTY", "")),
|
|
63
|
+
"delivered": _num(r.get("DELIV_QTY", "")),
|
|
64
|
+
"percent": _num(r.get("DELIV_PER", "")),
|
|
65
|
+
"trades": _num(r.get("NO_OF_TRADES", "")),
|
|
66
|
+
"avg_price": _num(r.get("AVG_PRICE", "")),
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _download() -> tuple[str, dict[str, dict]]:
|
|
72
|
+
from ..data.listings import _get
|
|
73
|
+
|
|
74
|
+
day = datetime.now(IST)
|
|
75
|
+
last_error: Optional[Exception] = None
|
|
76
|
+
for _ in range(DAYS_BACK):
|
|
77
|
+
try:
|
|
78
|
+
rows = parse(_get(URL.format(day=day)).text)
|
|
79
|
+
if rows:
|
|
80
|
+
return day.date().isoformat(), rows
|
|
81
|
+
except Exception as exc: # 404 before the close and on holidays
|
|
82
|
+
last_error = exc
|
|
83
|
+
day -= timedelta(days=1)
|
|
84
|
+
raise RuntimeError(f"No delivery file in the last {DAYS_BACK} days: {last_error}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_disk() -> None:
|
|
88
|
+
try:
|
|
89
|
+
saved = json.loads(_cache_file().read_text(encoding="utf-8"))
|
|
90
|
+
_state.update(date=saved["date"], rows=saved["rows"])
|
|
91
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _refresh() -> None:
|
|
96
|
+
try:
|
|
97
|
+
date, rows = _download()
|
|
98
|
+
with _lock:
|
|
99
|
+
_state.update(date=date, rows=rows)
|
|
100
|
+
try:
|
|
101
|
+
path = _cache_file()
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
tmp = path.with_suffix(".tmp")
|
|
104
|
+
tmp.write_text(json.dumps({"date": date, "rows": rows}), encoding="utf-8")
|
|
105
|
+
tmp.replace(path)
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
logger.debug("Could not save the delivery file: %s", exc)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.info("Delivery file not loaded: %s", exc)
|
|
110
|
+
finally:
|
|
111
|
+
with _lock:
|
|
112
|
+
_state["busy"] = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _newer_due(date: Optional[str], now: Optional[datetime] = None) -> bool:
|
|
116
|
+
"""A newer file can exist: none saved yet, or the saved one is older than
|
|
117
|
+
the latest weekday session whose file is out (published by ~18:00 IST).
|
|
118
|
+
Holidays only cost a recheck every CHECK_SECONDS that day."""
|
|
119
|
+
if not date:
|
|
120
|
+
return True
|
|
121
|
+
now = now or datetime.now(IST)
|
|
122
|
+
session = now.date() if now.hour >= 18 else now.date() - timedelta(days=1)
|
|
123
|
+
while session.weekday() >= 5: # Saturday / Sunday
|
|
124
|
+
session -= timedelta(days=1)
|
|
125
|
+
return date < session.isoformat()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get(symbol: str) -> dict:
|
|
129
|
+
"""Last session's delivery figures for one NSE symbol. Starts a
|
|
130
|
+
background fetch when due; never blocks."""
|
|
131
|
+
with _lock:
|
|
132
|
+
if not _state["rows"] and not _state["checked"]:
|
|
133
|
+
_load_disk()
|
|
134
|
+
start = (not _state["busy"] and time.monotonic() - _state["checked"] >= CHECK_SECONDS
|
|
135
|
+
and (_newer_due(_state["date"]) or not _state["checked"]))
|
|
136
|
+
if start:
|
|
137
|
+
_state.update(busy=True, checked=time.monotonic())
|
|
138
|
+
row = _state["rows"].get(symbol.upper())
|
|
139
|
+
date, loading = _state["date"], _state["busy"]
|
|
140
|
+
if start:
|
|
141
|
+
threading.Thread(target=_refresh, name="nse-delivery", daemon=True).start()
|
|
142
|
+
return {"date": date, "loading": loading and not row, **(row or {})}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Market discovery: index snapshot, breadth, top gainers / losers / most
|
|
2
|
+
active, 52-week extremes and sector performance for one market.
|
|
3
|
+
|
|
4
|
+
Everything except the index quotes comes from the listings store
|
|
5
|
+
(marketmyna/data/listings.py), which already carries each stock's price, day change,
|
|
6
|
+
volume, market cap, sector and 52-week range, so no extra downloads are needed.
|
|
7
|
+
Modeled on the OpenBB equity.discovery endpoints (Apache 2.0).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DISCOVERY_MARKETS = ("india", "us", "china_a", "hong_kong", "crypto")
|
|
21
|
+
INDEX_TTL = 60
|
|
22
|
+
TOP = 50 # rows per leader board (the table scrolls)
|
|
23
|
+
SECTOR_MEMBERS = 8 # stocks listed when a sector is pinned
|
|
24
|
+
|
|
25
|
+
_index_cache: dict[str, tuple[float, list[dict]]] = {}
|
|
26
|
+
_index_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _indices(market: str) -> list[dict]:
|
|
30
|
+
from .analysis_run import market_overview
|
|
31
|
+
|
|
32
|
+
now = time.time()
|
|
33
|
+
with _index_lock:
|
|
34
|
+
hit = _index_cache.get(market)
|
|
35
|
+
if hit and now - hit[0] < INDEX_TTL:
|
|
36
|
+
return hit[1]
|
|
37
|
+
try:
|
|
38
|
+
rows = market_overview([market])
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.warning("Index snapshot for %s failed: %s", market, exc)
|
|
41
|
+
rows = []
|
|
42
|
+
with _index_lock:
|
|
43
|
+
_index_cache[market] = (now, rows)
|
|
44
|
+
return rows
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _item(r: dict) -> dict:
|
|
48
|
+
price, volume = r.get("price"), r.get("volume")
|
|
49
|
+
return {
|
|
50
|
+
"symbol": r["symbol"], "name": r.get("name"), "price": price, "change_percent": r.get("change_percent"),
|
|
51
|
+
"volume": volume, "traded_value": price * volume if price and volume else None,
|
|
52
|
+
"market_cap": r.get("market_cap"), "currency": r.get("currency"), "sector": r.get("sector"),
|
|
53
|
+
"high_52w": r.get("high_52w"), "low_52w": r.get("low_52w"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _priced(r: dict) -> bool:
|
|
58
|
+
return r.get("price") is not None and r.get("change_percent") is not None and (r.get("volume") or 0) > 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build(market: str, size: int = 500, rows: Optional[list[dict]] = None, with_indices: bool = True,
|
|
62
|
+
live: Optional[dict] = None) -> dict:
|
|
63
|
+
"""Discovery payload. `size` = how many of the largest names (by market cap,
|
|
64
|
+
by volume for crypto) the leader boards rank, so illiquid micro-caps do not
|
|
65
|
+
flood the gainers list. Breadth always covers every priced stock.
|
|
66
|
+
|
|
67
|
+
`live` = live_market.market(): its quotes replace the snapshot prices, and
|
|
68
|
+
only live-priced names are ranked, so today's moves never mix with an
|
|
69
|
+
earlier session's."""
|
|
70
|
+
from ..data.listings import get_store
|
|
71
|
+
from .live_market import merge
|
|
72
|
+
|
|
73
|
+
store = get_store()
|
|
74
|
+
if rows is None:
|
|
75
|
+
rows = store.rows(market)
|
|
76
|
+
quotes = (live or {}).get("quotes") or {}
|
|
77
|
+
if quotes:
|
|
78
|
+
rows = merge(rows, quotes, only_live=True)
|
|
79
|
+
kind = "crypto" if market == "crypto" else "stock"
|
|
80
|
+
stocks = [r for r in rows if r.get("type") == kind and _priced(r)]
|
|
81
|
+
|
|
82
|
+
size_key = "volume" if market == "crypto" else "market_cap"
|
|
83
|
+
universe = sorted((r for r in stocks if r.get(size_key)), key=lambda r: -r[size_key])[:max(20, size)]
|
|
84
|
+
|
|
85
|
+
by_change = sorted(universe, key=lambda r: r["change_percent"])
|
|
86
|
+
traded = sorted(universe, key=lambda r: -(r["price"] * r["volume"]))
|
|
87
|
+
|
|
88
|
+
def near(r: dict, key: str) -> Optional[float]:
|
|
89
|
+
ref = r.get(key)
|
|
90
|
+
return (r["price"] / ref - 1) * 100 if ref else None
|
|
91
|
+
|
|
92
|
+
with_range = [r for r in universe if r.get("high_52w") and r.get("low_52w")]
|
|
93
|
+
near_high = sorted((r for r in with_range if near(r, "high_52w") >= -2), key=lambda r: -near(r, "high_52w"))
|
|
94
|
+
near_low = sorted((r for r in with_range if near(r, "low_52w") <= 2), key=lambda r: near(r, "low_52w"))
|
|
95
|
+
|
|
96
|
+
adv = sum(r["change_percent"] > 0 for r in stocks)
|
|
97
|
+
dec = sum(r["change_percent"] < 0 for r in stocks)
|
|
98
|
+
|
|
99
|
+
sectors: dict[str, dict] = {}
|
|
100
|
+
for r in universe:
|
|
101
|
+
# Broad category (same taxonomy in every market), not the exchange's fine-grained sector.
|
|
102
|
+
name = r.get("category")
|
|
103
|
+
if market == "crypto" or not name or name == "Other":
|
|
104
|
+
continue
|
|
105
|
+
s = sectors.setdefault(name, {"sector": name, "count": 0, "advancers": 0, "decliners": 0,
|
|
106
|
+
"_w": 0.0, "_wc": 0.0, "_rows": [], "leader": None, "laggard": None})
|
|
107
|
+
weight = r.get(size_key) or 0
|
|
108
|
+
s["count"] += 1
|
|
109
|
+
s["advancers"] += r["change_percent"] > 0
|
|
110
|
+
s["decliners"] += r["change_percent"] < 0
|
|
111
|
+
s["_w"] += weight
|
|
112
|
+
s["_wc"] += weight * r["change_percent"]
|
|
113
|
+
s["_rows"].append((weight, r))
|
|
114
|
+
if s["leader"] is None or r["change_percent"] > s["leader"]["change_percent"]:
|
|
115
|
+
s["leader"] = {"symbol": r["symbol"], "change_percent": r["change_percent"]}
|
|
116
|
+
if s["laggard"] is None or r["change_percent"] < s["laggard"]["change_percent"]:
|
|
117
|
+
s["laggard"] = {"symbol": r["symbol"], "change_percent": r["change_percent"]}
|
|
118
|
+
sector_rows = []
|
|
119
|
+
for s in sectors.values():
|
|
120
|
+
w, wc = s.pop("_w"), s.pop("_wc")
|
|
121
|
+
s["change_percent"] = wc / w if w else None
|
|
122
|
+
# Largest stocks first, so a pinned sector shows what drives its number.
|
|
123
|
+
biggest = sorted(s.pop("_rows"), key=lambda t: -t[0])[:SECTOR_MEMBERS]
|
|
124
|
+
s["members"] = [{"symbol": r["symbol"], "change_percent": r["change_percent"]} for _, r in biggest]
|
|
125
|
+
sector_rows.append(s)
|
|
126
|
+
sector_rows.sort(key=lambda s: -(s["change_percent"] if s["change_percent"] is not None else -1e9))
|
|
127
|
+
|
|
128
|
+
if quotes:
|
|
129
|
+
as_of = datetime.fromtimestamp(live["status"]["updated"]).isoformat(timespec="seconds")
|
|
130
|
+
else:
|
|
131
|
+
fetched = [s.get("fetched_at") for s in store.status() if s["market"] == market and s.get("fetched_at")]
|
|
132
|
+
as_of = max(fetched) if fetched else None
|
|
133
|
+
return {
|
|
134
|
+
"market": market,
|
|
135
|
+
"as_of": as_of,
|
|
136
|
+
"live": (live or {}).get("status"),
|
|
137
|
+
"universe": len(universe),
|
|
138
|
+
"indices": _indices(market) if with_indices else [],
|
|
139
|
+
"breadth": {"advancers": adv, "decliners": dec, "unchanged": len(stocks) - adv - dec, "total": len(stocks)},
|
|
140
|
+
"gainers": [_item(r) for r in reversed(by_change[-TOP:]) if r["change_percent"] > 0],
|
|
141
|
+
"losers": [_item(r) for r in by_change[:TOP] if r["change_percent"] < 0],
|
|
142
|
+
"active": [_item(r) for r in traded[:TOP]],
|
|
143
|
+
"near_high": [_item(r) for r in near_high[:TOP]],
|
|
144
|
+
"near_low": [_item(r) for r in near_low[:TOP]],
|
|
145
|
+
"sectors": sector_rows,
|
|
146
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Dividend history (OpenBB equity.fundamental.dividends / trailing_dividend_yield).
|
|
2
|
+
|
|
3
|
+
Computed from the per-payment history Yahoo publishes (split-adjusted), not
|
|
4
|
+
its summary fields, which are often stale for Indian stocks: trailing-12-month
|
|
5
|
+
payout and yield, yearly totals, 5-year growth, the run of years without a
|
|
6
|
+
cut, and how often it pays.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ..core.numbers import finite as _f
|
|
17
|
+
from ..data.yahoo_price import fresh_price, info_price
|
|
18
|
+
|
|
19
|
+
Fetch = Callable[[str], dict]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _yahoo(yf_symbol: str) -> dict:
|
|
23
|
+
import yfinance as yf
|
|
24
|
+
|
|
25
|
+
t = yf.Ticker(yf_symbol)
|
|
26
|
+
try:
|
|
27
|
+
info = t.info or {}
|
|
28
|
+
except Exception:
|
|
29
|
+
info = {}
|
|
30
|
+
return {"dividends": t.dividends, "info": info, "price": fresh_price(info, t)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build(yf_symbol: str, fetch: Fetch = _yahoo, today: Optional[pd.Timestamp] = None) -> dict:
|
|
34
|
+
raw = fetch(yf_symbol)
|
|
35
|
+
s: pd.Series = raw.get("dividends")
|
|
36
|
+
info = raw.get("info") or {}
|
|
37
|
+
price = _f(raw.get("price")) if "price" in raw else info_price(info)
|
|
38
|
+
if s is None or len(s) == 0:
|
|
39
|
+
return {"pays": False, "currency": info.get("currency")}
|
|
40
|
+
idx = pd.DatetimeIndex(s.index)
|
|
41
|
+
s = pd.Series(s.to_numpy(dtype=float), index=idx.tz_localize(None) if idx.tz is not None else idx).sort_index()
|
|
42
|
+
s = s[s > 0]
|
|
43
|
+
if s.empty:
|
|
44
|
+
return {"pays": False, "currency": info.get("currency")}
|
|
45
|
+
today = (today or pd.Timestamp.today()).normalize()
|
|
46
|
+
|
|
47
|
+
ttm = float(s[s.index > today - pd.Timedelta(days=365)].sum())
|
|
48
|
+
yearly = s.groupby(s.index.year).sum()
|
|
49
|
+
complete = yearly[yearly.index < today.year]
|
|
50
|
+
if len(complete): # years with no payment at all count as zero (a cut), not as missing
|
|
51
|
+
complete = complete.reindex(range(int(complete.index.min()), today.year), fill_value=0.0)
|
|
52
|
+
years = [{"year": int(y), "total": float(v), "partial": False} for y, v in complete.items() if v > 0]
|
|
53
|
+
if today.year in yearly.index:
|
|
54
|
+
years.append({"year": today.year, "total": float(yearly[today.year]), "partial": True})
|
|
55
|
+
|
|
56
|
+
cagr = None
|
|
57
|
+
if len(complete) >= 6 and complete.iloc[-6] > 0:
|
|
58
|
+
cagr = float(((complete.iloc[-1] / complete.iloc[-6]) ** (1 / 5) - 1) * 100)
|
|
59
|
+
|
|
60
|
+
# consecutive complete years without a cut, counting back from the latest
|
|
61
|
+
streak = 0
|
|
62
|
+
vals = complete.to_numpy()
|
|
63
|
+
for i in range(len(vals) - 1, 0, -1):
|
|
64
|
+
if vals[i - 1] > 0 and vals[i] >= vals[i - 1] * 0.995:
|
|
65
|
+
streak += 1
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
per_year = int((s.index > today - pd.Timedelta(days=365)).sum())
|
|
70
|
+
freq = {0: None, 1: "Yearly", 2: "Twice a year", 3: "Three times a year", 4: "Quarterly"}.get(per_year, "Monthly" if per_year >= 10 else f"{per_year} times a year")
|
|
71
|
+
|
|
72
|
+
next_ex = None
|
|
73
|
+
if ts := _f(info.get("exDividendDate")):
|
|
74
|
+
d = datetime.fromtimestamp(ts, tz=timezone.utc).date()
|
|
75
|
+
if pd.Timestamp(d) >= today:
|
|
76
|
+
next_ex = d.isoformat()
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"pays": True,
|
|
80
|
+
"currency": info.get("currency"),
|
|
81
|
+
"ttm": ttm,
|
|
82
|
+
"ttm_yield": ttm / price * 100 if price and ttm else None,
|
|
83
|
+
"forward_rate": _f(info.get("dividendRate")),
|
|
84
|
+
"forward_yield": _f(info.get("dividendRate")) / price * 100 if price and _f(info.get("dividendRate")) else None,
|
|
85
|
+
"five_year_avg_yield": _f(info.get("fiveYearAvgDividendYield")),
|
|
86
|
+
"payout_ratio": _f(info.get("payoutRatio")) * 100 if _f(info.get("payoutRatio")) is not None else None,
|
|
87
|
+
"growth_5y": cagr,
|
|
88
|
+
"years_without_cut": streak,
|
|
89
|
+
"frequency": freq,
|
|
90
|
+
"last": {"date": str(s.index[-1].date()), "amount": float(s.iloc[-1])},
|
|
91
|
+
"next_ex_date": next_ex,
|
|
92
|
+
"years": years[-12:],
|
|
93
|
+
"payments": [{"date": str(d.date()), "amount": float(v)} for d, v in s.iloc[-16:].items()][::-1],
|
|
94
|
+
"first_year": int(s.index[0].year),
|
|
95
|
+
}
|