dividendcase 0.1.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 (188) hide show
  1. dividendcase/__init__.py +7 -0
  2. dividendcase/__main__.py +4 -0
  3. dividendcase/api/__init__.py +0 -0
  4. dividendcase/api/v1/__init__.py +0 -0
  5. dividendcase/api/v1/data.py +34 -0
  6. dividendcase/api/v1/deps.py +10 -0
  7. dividendcase/api/v1/dividends.py +238 -0
  8. dividendcase/api/v1/excel_io.py +1093 -0
  9. dividendcase/api/v1/fx.py +29 -0
  10. dividendcase/api/v1/health.py +38 -0
  11. dividendcase/api/v1/investment.py +261 -0
  12. dividendcase/api/v1/portfolio.py +706 -0
  13. dividendcase/api/v1/portfolios.py +147 -0
  14. dividendcase/api/v1/router.py +17 -0
  15. dividendcase/api/v1/search.py +181 -0
  16. dividendcase/api/v1/stocks.py +292 -0
  17. dividendcase/api/v1/user.py +108 -0
  18. dividendcase/api/v1/watchlist.py +289 -0
  19. dividendcase/cli.py +72 -0
  20. dividendcase/config.py +45 -0
  21. dividendcase/crud/__init__.py +7 -0
  22. dividendcase/crud/dividend.py +57 -0
  23. dividendcase/crud/stock.py +78 -0
  24. dividendcase/database.py +38 -0
  25. dividendcase/main.py +115 -0
  26. dividendcase/migrate.py +161 -0
  27. dividendcase/migrations/env.py +35 -0
  28. dividendcase/migrations/script.py.mako +24 -0
  29. dividendcase/migrations/versions/0001_baseline.py +180 -0
  30. dividendcase/migrations/versions/0002_check_for_updates.py +26 -0
  31. dividendcase/migrations/versions/0003_fx_rates_and_setup.py +41 -0
  32. dividendcase/models/__init__.py +21 -0
  33. dividendcase/models/dividend.py +18 -0
  34. dividendcase/models/fx_rate.py +14 -0
  35. dividendcase/models/scheduler_run.py +14 -0
  36. dividendcase/models/stock.py +29 -0
  37. dividendcase/models/user_investment.py +24 -0
  38. dividendcase/models/user_portfolio.py +12 -0
  39. dividendcase/models/user_preferences.py +22 -0
  40. dividendcase/models/user_watchlist.py +17 -0
  41. dividendcase/models/user_watchlist_group.py +12 -0
  42. dividendcase/scheduler/__init__.py +0 -0
  43. dividendcase/scheduler/tasks.py +7 -0
  44. dividendcase/schemas/__init__.py +9 -0
  45. dividendcase/schemas/dividend.py +55 -0
  46. dividendcase/schemas/investment.py +21 -0
  47. dividendcase/schemas/stock.py +47 -0
  48. dividendcase/schemas/user_investment.py +126 -0
  49. dividendcase/schemas/user_preferences.py +34 -0
  50. dividendcase/schemas/watchlist.py +42 -0
  51. dividendcase/services/__init__.py +0 -0
  52. dividendcase/services/derived.py +33 -0
  53. dividendcase/services/fx.py +204 -0
  54. dividendcase/services/refresh.py +318 -0
  55. dividendcase/services/universe.py +193 -0
  56. dividendcase/services/updates.py +115 -0
  57. dividendcase/services/yahoo_fetcher.py +856 -0
  58. dividendcase/web/.gitkeep +0 -0
  59. dividendcase/web/404/index.html +1 -0
  60. dividendcase/web/404.html +1 -0
  61. dividendcase/web/__next.__PAGE__.txt +23 -0
  62. dividendcase/web/__next._full.txt +21 -0
  63. dividendcase/web/__next._tree.txt +5 -0
  64. dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_buildManifest.js +11 -0
  65. dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_clientMiddlewareManifest.js +1 -0
  66. dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_ssgManifest.js +1 -0
  67. dividendcase/web/_next/static/chunks/0c87cux8r1ss5.js +1 -0
  68. dividendcase/web/_next/static/chunks/0cev4x3kzarbu.js +2 -0
  69. dividendcase/web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  70. dividendcase/web/_next/static/chunks/0g-bslilw6tiu.js +1 -0
  71. dividendcase/web/_next/static/chunks/0izc_4m5nyqrd.js +1 -0
  72. dividendcase/web/_next/static/chunks/0lfebwmsk92ym.js +1 -0
  73. dividendcase/web/_next/static/chunks/0nmuijbkm3ipd.js +1 -0
  74. dividendcase/web/_next/static/chunks/0ozczpntpufql.js +1 -0
  75. dividendcase/web/_next/static/chunks/1-ift1mtsuqvh.js +1 -0
  76. dividendcase/web/_next/static/chunks/10oy-ed_zrrd9.js +1 -0
  77. dividendcase/web/_next/static/chunks/13-u82nfffhrp.js +1 -0
  78. dividendcase/web/_next/static/chunks/138wmfea-yk08.js +22 -0
  79. dividendcase/web/_next/static/chunks/14jt62gawmtd9.js +1 -0
  80. dividendcase/web/_next/static/chunks/15m8s8ua7v57_.js +1 -0
  81. dividendcase/web/_next/static/chunks/193r1px8kfm06.js +1 -0
  82. dividendcase/web/_next/static/chunks/1l-w55cdq629z.js +1 -0
  83. dividendcase/web/_next/static/chunks/1q2bxv0xj7vse.js +1 -0
  84. dividendcase/web/_next/static/chunks/1s5tue3q2i-8b.js +1 -0
  85. dividendcase/web/_next/static/chunks/1xrz4-jij46i_.js +1 -0
  86. dividendcase/web/_next/static/chunks/24f36pdyspt8c.js +1 -0
  87. dividendcase/web/_next/static/chunks/2aqpz6z087bo4.js +1 -0
  88. dividendcase/web/_next/static/chunks/2ft48jvqut7tm.js +1 -0
  89. dividendcase/web/_next/static/chunks/2i51e627rllld.js +1 -0
  90. dividendcase/web/_next/static/chunks/2o72ftwupypgx.js +1 -0
  91. dividendcase/web/_next/static/chunks/2vq-8ocdpjh4a.css +4 -0
  92. dividendcase/web/_next/static/chunks/33-cg7-y-5zm5.js +1 -0
  93. dividendcase/web/_next/static/chunks/3cju952ndgmmn.js +1 -0
  94. dividendcase/web/_next/static/chunks/3il2yixgws-43.js +1 -0
  95. dividendcase/web/_next/static/chunks/3mc6dra1m0098.js +31 -0
  96. dividendcase/web/_next/static/chunks/431n5e16l34_0.js +1 -0
  97. dividendcase/web/_next/static/chunks/453cd1tlxz5wi.js +1 -0
  98. dividendcase/web/_next/static/chunks/turbopack-1che7mqzsn0nh.js +1 -0
  99. dividendcase/web/_next/static/media/4fa387ec64143e14-s.2tuy5pz7dlieh.woff2 +0 -0
  100. dividendcase/web/_next/static/media/53b9e256198e5412-s.390ncx5urfkfu.woff2 +0 -0
  101. dividendcase/web/_next/static/media/5ce348bf30bf5439-s.31988l_ccedte.woff2 +0 -0
  102. dividendcase/web/_next/static/media/6306c77e7c8268e4-s.2dbetqa9o8jxf.woff2 +0 -0
  103. dividendcase/web/_next/static/media/7178b3e590c64307-s.21jp631_3pja2.woff2 +0 -0
  104. dividendcase/web/_next/static/media/797e433ab948586e-s.p.0r6juujl39pe6.woff2 +0 -0
  105. dividendcase/web/_next/static/media/7d817b4c03b0c5f1-s.1uyisp29ctx0d.woff2 +0 -0
  106. dividendcase/web/_next/static/media/7ebf22b5a21034f8-s.p.3j3877k49yy0l.woff2 +0 -0
  107. dividendcase/web/_next/static/media/8a480f0b521d4e75-s.1qq4vpdcun5oj.woff2 +0 -0
  108. dividendcase/web/_next/static/media/bbc41e54d2fcbd21-s.1rgnod-3esatf.woff2 +0 -0
  109. dividendcase/web/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2 +0 -0
  110. dividendcase/web/_next/static/media/fc0a7394b875fa43-s.2ytmazkdrg7t9.woff2 +0 -0
  111. dividendcase/web/_next/static/media/fef07dbb0973bf53-s.3p2_lha1f2xer.woff2 +0 -0
  112. dividendcase/web/_next/static/media/icon.0kxjuvpgwz_c6.svg +85 -0
  113. dividendcase/web/_not-found/__next._full.txt +16 -0
  114. dividendcase/web/_not-found/__next._not-found.__PAGE__.txt +22 -0
  115. dividendcase/web/_not-found/__next._tree.txt +2 -0
  116. dividendcase/web/_not-found/index.html +1 -0
  117. dividendcase/web/_not-found/index.txt +16 -0
  118. dividendcase/web/dashboard/__next._full.txt +24 -0
  119. dividendcase/web/dashboard/__next._tree.txt +5 -0
  120. dividendcase/web/dashboard/__next.dashboard.__PAGE__.txt +27 -0
  121. dividendcase/web/dashboard/about/__next._full.txt +26 -0
  122. dividendcase/web/dashboard/about/__next._tree.txt +5 -0
  123. dividendcase/web/dashboard/about/__next.dashboard.about.__PAGE__.txt +30 -0
  124. dividendcase/web/dashboard/about/index.html +1 -0
  125. dividendcase/web/dashboard/about/index.txt +26 -0
  126. dividendcase/web/dashboard/calendar/__next._full.txt +26 -0
  127. dividendcase/web/dashboard/calendar/__next._tree.txt +5 -0
  128. dividendcase/web/dashboard/calendar/__next.dashboard.calendar.__PAGE__.txt +30 -0
  129. dividendcase/web/dashboard/calendar/index.html +1 -0
  130. dividendcase/web/dashboard/calendar/index.txt +26 -0
  131. dividendcase/web/dashboard/compare/__next._full.txt +26 -0
  132. dividendcase/web/dashboard/compare/__next._tree.txt +5 -0
  133. dividendcase/web/dashboard/compare/__next.dashboard.compare.__PAGE__.txt +30 -0
  134. dividendcase/web/dashboard/compare/index.html +1 -0
  135. dividendcase/web/dashboard/compare/index.txt +26 -0
  136. dividendcase/web/dashboard/data/__next._full.txt +23 -0
  137. dividendcase/web/dashboard/data/__next._tree.txt +5 -0
  138. dividendcase/web/dashboard/data/__next.dashboard.data.__PAGE__.txt +27 -0
  139. dividendcase/web/dashboard/data/index.html +1 -0
  140. dividendcase/web/dashboard/data/index.txt +23 -0
  141. dividendcase/web/dashboard/drip/__next._full.txt +26 -0
  142. dividendcase/web/dashboard/drip/__next._tree.txt +5 -0
  143. dividendcase/web/dashboard/drip/__next.dashboard.drip.__PAGE__.txt +30 -0
  144. dividendcase/web/dashboard/drip/index.html +1 -0
  145. dividendcase/web/dashboard/drip/index.txt +26 -0
  146. dividendcase/web/dashboard/index.html +1 -0
  147. dividendcase/web/dashboard/index.txt +24 -0
  148. dividendcase/web/dashboard/investments/__next._full.txt +26 -0
  149. dividendcase/web/dashboard/investments/__next._tree.txt +5 -0
  150. dividendcase/web/dashboard/investments/__next.dashboard.investments.__PAGE__.txt +30 -0
  151. dividendcase/web/dashboard/investments/index.html +1 -0
  152. dividendcase/web/dashboard/investments/index.txt +26 -0
  153. dividendcase/web/dashboard/investments/portfolio/__next._full.txt +26 -0
  154. dividendcase/web/dashboard/investments/portfolio/__next._tree.txt +5 -0
  155. dividendcase/web/dashboard/investments/portfolio/__next.dashboard.investments.portfolio.__PAGE__.txt +31 -0
  156. dividendcase/web/dashboard/investments/portfolio/index.html +1 -0
  157. dividendcase/web/dashboard/investments/portfolio/index.txt +26 -0
  158. dividendcase/web/dashboard/screener/__next._full.txt +26 -0
  159. dividendcase/web/dashboard/screener/__next._tree.txt +5 -0
  160. dividendcase/web/dashboard/screener/__next.dashboard.screener.__PAGE__.txt +30 -0
  161. dividendcase/web/dashboard/screener/index.html +1 -0
  162. dividendcase/web/dashboard/screener/index.txt +26 -0
  163. dividendcase/web/dashboard/settings/__next._full.txt +26 -0
  164. dividendcase/web/dashboard/settings/__next._tree.txt +5 -0
  165. dividendcase/web/dashboard/settings/__next.dashboard.settings.__PAGE__.txt +30 -0
  166. dividendcase/web/dashboard/settings/index.html +1 -0
  167. dividendcase/web/dashboard/settings/index.txt +26 -0
  168. dividendcase/web/dashboard/stock/__next._full.txt +26 -0
  169. dividendcase/web/dashboard/stock/__next._tree.txt +5 -0
  170. dividendcase/web/dashboard/stock/__next.dashboard.stock.__PAGE__.txt +30 -0
  171. dividendcase/web/dashboard/stock/index.html +1 -0
  172. dividendcase/web/dashboard/stock/index.txt +26 -0
  173. dividendcase/web/dashboard/watchlist/__next._full.txt +26 -0
  174. dividendcase/web/dashboard/watchlist/__next._tree.txt +5 -0
  175. dividendcase/web/dashboard/watchlist/__next.dashboard.watchlist.__PAGE__.txt +30 -0
  176. dividendcase/web/dashboard/watchlist/index.html +1 -0
  177. dividendcase/web/dashboard/watchlist/index.txt +26 -0
  178. dividendcase/web/icon.svg +85 -0
  179. dividendcase/web/index.html +1 -0
  180. dividendcase/web/index.txt +21 -0
  181. dividendcase/web/logo-full.svg +105 -0
  182. dividendcase/web/logo-icon.svg +85 -0
  183. dividendcase/web/tickers.json +1 -0
  184. dividendcase-0.1.0.dist-info/METADATA +172 -0
  185. dividendcase-0.1.0.dist-info/RECORD +188 -0
  186. dividendcase-0.1.0.dist-info/WHEEL +4 -0
  187. dividendcase-0.1.0.dist-info/entry_points.txt +2 -0
  188. dividendcase-0.1.0.dist-info/licenses/LICENSE +662 -0
@@ -0,0 +1,7 @@
1
+ """DividendCase: a free, open-source dividend tracker that runs on your own computer."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("dividendcase")
6
+ except PackageNotFoundError: # running from a source checkout without installing
7
+ __version__ = "0.0.0+local"
@@ -0,0 +1,4 @@
1
+ # `python -m dividendcase` — the fallback when Windows Smart App Control blocks the launcher
2
+ from dividendcase.cli import main
3
+
4
+ main()
File without changes
File without changes
@@ -0,0 +1,34 @@
1
+ """Data refresh status and controls, shown on the Data page."""
2
+ from typing import Literal
3
+
4
+ from fastapi import APIRouter
5
+ from pydantic import BaseModel
6
+
7
+ from dividendcase.services.fx import rates_summary
8
+ from dividendcase.services.universe import markets
9
+ from dividendcase.services.refresh import data_summary, queue_holdings, queue_screener, refresher
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class RefreshRequest(BaseModel):
15
+ scope: Literal["holdings", "screener"]
16
+ # holdings only: refetch everything, not just what's missing or older than a day
17
+ force: bool = False
18
+
19
+
20
+ @router.get("/status")
21
+ async def refresh_status():
22
+ return {**refresher.status(), **await data_summary(), **await rates_summary()}
23
+
24
+
25
+ @router.get("/markets")
26
+ async def screener_markets():
27
+ """The market groups the screener can download (first-run setup and Settings)."""
28
+ return {"markets": markets()}
29
+
30
+
31
+ @router.post("/refresh", status_code=202)
32
+ async def start_refresh(body: RefreshRequest):
33
+ queued = await queue_holdings(force=body.force) if body.scope == "holdings" else await queue_screener()
34
+ return {"queued": queued}
@@ -0,0 +1,10 @@
1
+ """Request dependencies shared by the API routes."""
2
+ from uuid import UUID
3
+
4
+ # The local app has exactly one user: whoever runs it. Every row that belongs to a
5
+ # user carries this id, so the multi-user schema works unchanged.
6
+ LOCAL_USER_ID = UUID("00000000-0000-0000-0000-000000000001")
7
+
8
+
9
+ def _get_user_id() -> UUID:
10
+ return LOCAL_USER_ID
@@ -0,0 +1,238 @@
1
+ from fastapi import APIRouter, Depends, Query, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from dividendcase.database import get_db
4
+ from dividendcase.crud.stock import get_stock
5
+ from dividendcase.crud.dividend import get_dividends_for_ticker
6
+ from dividendcase.schemas.dividend import DividendHistoryResponse, DividendMetrics
7
+
8
+ router = APIRouter()
9
+
10
+ CURRENCY_SYMBOLS = {
11
+ "USD": "$", "CAD": "C$", "GBP": "£", "EUR": "€",
12
+ "AUD": "A$", "INR": "₹", "JPY": "¥", "HKD": "HK$",
13
+ "SGD": "S$", "CHF": "CHF",
14
+ }
15
+
16
+
17
+ def _calculate_metrics(ticker: str, company_name: str, currency: str, records) -> DividendMetrics:
18
+ if not records:
19
+ return DividendMetrics(
20
+ ticker_symbol=ticker, company_name=company_name,
21
+ total_payments=0, currency=currency,
22
+ )
23
+
24
+ from datetime import datetime, timedelta
25
+ import statistics
26
+
27
+ sorted_records = sorted(records, key=lambda r: r.dividend_date)
28
+ last_record = sorted_records[-1]
29
+ per_payment_yields = [float(r.dividend_yield_pct) for r in sorted_records if r.dividend_yield_pct]
30
+ last_price = float(last_record.share_price_on_dividend_date) if last_record.share_price_on_dividend_date else None
31
+ last_amount = float(last_record.dividend_per_share) if last_record.dividend_per_share else None
32
+
33
+ # ── Payment frequency ────────────────────────────────────────────────────
34
+ dates = [r.dividend_date for r in sorted_records]
35
+ if len(dates) >= 2:
36
+ gaps = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)]
37
+ avg_gap = sum(gaps) / len(gaps)
38
+ if avg_gap < 45:
39
+ freq, payments_per_year = "monthly", 12
40
+ elif avg_gap < 120:
41
+ freq, payments_per_year = "quarterly", 4
42
+ elif avg_gap < 270:
43
+ freq, payments_per_year = "semi-annual", 2
44
+ else:
45
+ freq, payments_per_year = "annual", 1
46
+ else:
47
+ freq, payments_per_year = "unknown", 4
48
+
49
+ # Annual dividend estimate: last payment × frequency
50
+ # e.g. ORC $0.12/month × 12 = $1.44/year
51
+ annual_estimate = round(last_amount * payments_per_year, 4) if last_amount else None
52
+
53
+ # TTM yield (Trailing 12-Month) — industry standard "current yield"
54
+ # Sum of all dividends paid in the last 365 days ÷ last known price × 100
55
+ # This is what Yahoo Finance, Morningstar, and Bloomberg show as "yield"
56
+ # e.g. ORC: $1.44 paid in last 12 months / $4.75 price = 30.3%
57
+ today = datetime.now().date()
58
+ ttm_amount = sum(
59
+ float(r.dividend_per_share)
60
+ for r in sorted_records
61
+ if r.dividend_date >= (today - timedelta(days=365)) and r.dividend_per_share
62
+ )
63
+ ttm_yield = round(ttm_amount / last_price * 100, 2) if last_price and ttm_amount > 0 else None
64
+
65
+ # Projected yield: last payment annualised at last known price
66
+ # Answers: "what yield would I earn if the most recent payment repeats for a year?"
67
+ # More forward-looking than TTM; same as TTM when payout is stable
68
+ current_yield = (
69
+ round(last_amount * payments_per_year / last_price * 100, 2)
70
+ if last_amount and last_price and last_price > 0
71
+ else None
72
+ )
73
+
74
+ # Historical average annualised yield over the selected period
75
+ # Per-payment yield × annual frequency, averaged across all records
76
+ avg_yield = (
77
+ round((sum(per_payment_yields) / len(per_payment_yields)) * payments_per_year, 2)
78
+ if per_payment_yields
79
+ else None
80
+ )
81
+
82
+ # Consistency score: 100 = perfectly stable yield, 0 = highly erratic
83
+ # Based on coefficient of variation of per-payment yields
84
+ consistency = None
85
+ if len(per_payment_yields) > 1:
86
+ try:
87
+ std = statistics.stdev(per_payment_yields)
88
+ mean = statistics.mean(per_payment_yields)
89
+ cv = std / mean if mean else 1.0
90
+ consistency = round(max(0.0, min(100.0, (1 - cv) * 100)), 1)
91
+ except Exception:
92
+ pass
93
+
94
+ # ── Annual dividends aggregation ─────────────────────────────────────────
95
+ from collections import defaultdict
96
+ annual_sums: dict[int, float] = defaultdict(float)
97
+ for r in sorted_records:
98
+ annual_sums[r.dividend_date.year] += float(r.dividend_per_share)
99
+ annual_dividends = {yr: round(amt, 4) for yr, amt in sorted(annual_sums.items())}
100
+
101
+ # ── Dividend Growth CAGR ─────────────────────────────────────────────────
102
+ def _cagr(annual_divs: dict[int, float], n_years: int):
103
+ """Compute CAGR over the last n_years of annual dividend data."""
104
+ current_year = today.year
105
+ # Use previous full year as end (current year may be incomplete)
106
+ end_year = current_year - 1
107
+ start_year = end_year - n_years
108
+ if start_year in annual_divs and end_year in annual_divs:
109
+ start_val = annual_divs[start_year]
110
+ end_val = annual_divs[end_year]
111
+ if start_val > 0 and end_val > 0:
112
+ return round(((end_val / start_val) ** (1 / n_years) - 1) * 100, 2)
113
+ return None
114
+
115
+ cagr_3y = _cagr(annual_dividends, 3)
116
+ cagr_5y = _cagr(annual_dividends, 5)
117
+ cagr_10y = _cagr(annual_dividends, 10)
118
+
119
+ # ── Consecutive growth streak ────────────────────────────────────────────
120
+ sorted_years = sorted(annual_dividends.keys(), reverse=True)
121
+ consecutive_growth = 0
122
+ if len(sorted_years) >= 2:
123
+ # Skip current (possibly incomplete) year
124
+ check_years = [y for y in sorted_years if y < today.year]
125
+ for i in range(len(check_years) - 1):
126
+ yr = check_years[i]
127
+ prev_yr = check_years[i + 1]
128
+ if prev_yr == yr - 1 and annual_dividends[yr] >= annual_dividends[prev_yr] * 0.98:
129
+ consecutive_growth += 1
130
+ else:
131
+ break
132
+
133
+ # ── Dividend Safety Score (0-100) ────────────────────────────────────────
134
+ # Weighted composite:
135
+ # Yield consistency (40%) + Growth streak (25%) + History length (15%)
136
+ # + No cuts penalty (10%) + Yield sustainability (10%)
137
+ safety_score = None
138
+ safety_label = None
139
+ if len(sorted_records) >= 2:
140
+ # Yield consistency component (40%)
141
+ consistency_component = (consistency or 0) * 0.40
142
+
143
+ # Growth streak component (25%): max at 10+ years
144
+ streak_component = min(consecutive_growth / 10, 1.0) * 100 * 0.25
145
+
146
+ # History length component (15%): max at 10+ years of data
147
+ years_of_data = len(annual_dividends)
148
+ history_component = min(years_of_data / 10, 1.0) * 100 * 0.15
149
+
150
+ # No cuts penalty (10%): look at last 5 full years
151
+ recent_years = [y for y in sorted_years if y < today.year][:6] # up to 6 to check 5 transitions
152
+ cuts_count = 0
153
+ for i in range(len(recent_years) - 1):
154
+ yr = recent_years[i]
155
+ prev_yr = recent_years[i + 1]
156
+ if prev_yr == yr - 1 and annual_dividends[yr] < annual_dividends[prev_yr] * 0.95:
157
+ cuts_count += 1
158
+ if cuts_count == 0:
159
+ cuts_component = 100 * 0.10
160
+ elif cuts_count == 1:
161
+ cuts_component = 50 * 0.10
162
+ else:
163
+ cuts_component = 0
164
+
165
+ # Yield sustainability (10%): flag extreme yields as risky
166
+ effective_yield = ttm_yield or (current_yield if current_yield else 0)
167
+ if effective_yield < 8:
168
+ sustain_component = 100 * 0.10
169
+ elif effective_yield < 15:
170
+ sustain_component = 50 * 0.10
171
+ else:
172
+ sustain_component = 0
173
+
174
+ safety_score = round(
175
+ consistency_component + streak_component + history_component
176
+ + cuts_component + sustain_component,
177
+ 1,
178
+ )
179
+ safety_score = max(0.0, min(100.0, safety_score))
180
+
181
+ if safety_score >= 70:
182
+ safety_label = "Safe"
183
+ elif safety_score >= 40:
184
+ safety_label = "Moderate"
185
+ else:
186
+ safety_label = "At Risk"
187
+
188
+ return DividendMetrics(
189
+ ticker_symbol=ticker,
190
+ company_name=company_name,
191
+ ttm_yield=ttm_yield,
192
+ current_yield=current_yield,
193
+ avg_yield=avg_yield,
194
+ yield_consistency_score=consistency,
195
+ payment_frequency=freq,
196
+ payments_per_year=payments_per_year,
197
+ total_payments=len(records),
198
+ last_dividend_date=last_record.dividend_date,
199
+ last_dividend_amount=last_amount,
200
+ annual_dividend_estimate=annual_estimate,
201
+ currency=currency,
202
+ dividend_safety_score=safety_score,
203
+ safety_label=safety_label,
204
+ dividend_growth_cagr_3y=cagr_3y,
205
+ dividend_growth_cagr_5y=cagr_5y,
206
+ dividend_growth_cagr_10y=cagr_10y,
207
+ consecutive_growth_years=consecutive_growth,
208
+ annual_dividends=annual_dividends if annual_dividends else None,
209
+ )
210
+
211
+
212
+ @router.get("/{ticker}", response_model=DividendHistoryResponse)
213
+ async def dividend_history(
214
+ ticker: str,
215
+ years: int = Query(10, ge=1, le=20),
216
+ db: AsyncSession = Depends(get_db),
217
+ ):
218
+ ticker = ticker.upper()
219
+ stock = await get_stock(db, ticker)
220
+ if not stock:
221
+ raise HTTPException(status_code=404, detail=f"Stock {ticker} not found in database")
222
+
223
+ records = await get_dividends_for_ticker(db, ticker, years=years)
224
+ metrics = _calculate_metrics(
225
+ ticker,
226
+ stock.company_name,
227
+ stock.currency or "USD",
228
+ records,
229
+ )
230
+
231
+ return DividendHistoryResponse(
232
+ ticker_symbol=ticker,
233
+ company_name=stock.company_name,
234
+ exchange=stock.exchange,
235
+ currency=stock.currency or "USD",
236
+ records=records,
237
+ metrics=metrics,
238
+ )