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,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
+ }