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.
- dividendcase/__init__.py +7 -0
- dividendcase/__main__.py +4 -0
- dividendcase/api/__init__.py +0 -0
- dividendcase/api/v1/__init__.py +0 -0
- dividendcase/api/v1/data.py +34 -0
- dividendcase/api/v1/deps.py +10 -0
- dividendcase/api/v1/dividends.py +238 -0
- dividendcase/api/v1/excel_io.py +1093 -0
- dividendcase/api/v1/fx.py +29 -0
- dividendcase/api/v1/health.py +38 -0
- dividendcase/api/v1/investment.py +261 -0
- dividendcase/api/v1/portfolio.py +706 -0
- dividendcase/api/v1/portfolios.py +147 -0
- dividendcase/api/v1/router.py +17 -0
- dividendcase/api/v1/search.py +181 -0
- dividendcase/api/v1/stocks.py +292 -0
- dividendcase/api/v1/user.py +108 -0
- dividendcase/api/v1/watchlist.py +289 -0
- dividendcase/cli.py +72 -0
- dividendcase/config.py +45 -0
- dividendcase/crud/__init__.py +7 -0
- dividendcase/crud/dividend.py +57 -0
- dividendcase/crud/stock.py +78 -0
- dividendcase/database.py +38 -0
- dividendcase/main.py +115 -0
- dividendcase/migrate.py +161 -0
- dividendcase/migrations/env.py +35 -0
- dividendcase/migrations/script.py.mako +24 -0
- dividendcase/migrations/versions/0001_baseline.py +180 -0
- dividendcase/migrations/versions/0002_check_for_updates.py +26 -0
- dividendcase/migrations/versions/0003_fx_rates_and_setup.py +41 -0
- dividendcase/models/__init__.py +21 -0
- dividendcase/models/dividend.py +18 -0
- dividendcase/models/fx_rate.py +14 -0
- dividendcase/models/scheduler_run.py +14 -0
- dividendcase/models/stock.py +29 -0
- dividendcase/models/user_investment.py +24 -0
- dividendcase/models/user_portfolio.py +12 -0
- dividendcase/models/user_preferences.py +22 -0
- dividendcase/models/user_watchlist.py +17 -0
- dividendcase/models/user_watchlist_group.py +12 -0
- dividendcase/scheduler/__init__.py +0 -0
- dividendcase/scheduler/tasks.py +7 -0
- dividendcase/schemas/__init__.py +9 -0
- dividendcase/schemas/dividend.py +55 -0
- dividendcase/schemas/investment.py +21 -0
- dividendcase/schemas/stock.py +47 -0
- dividendcase/schemas/user_investment.py +126 -0
- dividendcase/schemas/user_preferences.py +34 -0
- dividendcase/schemas/watchlist.py +42 -0
- dividendcase/services/__init__.py +0 -0
- dividendcase/services/derived.py +33 -0
- dividendcase/services/fx.py +204 -0
- dividendcase/services/refresh.py +318 -0
- dividendcase/services/universe.py +193 -0
- dividendcase/services/updates.py +115 -0
- dividendcase/services/yahoo_fetcher.py +856 -0
- dividendcase/web/.gitkeep +0 -0
- dividendcase/web/404/index.html +1 -0
- dividendcase/web/404.html +1 -0
- dividendcase/web/__next.__PAGE__.txt +23 -0
- dividendcase/web/__next._full.txt +21 -0
- dividendcase/web/__next._tree.txt +5 -0
- dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_buildManifest.js +11 -0
- dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_clientMiddlewareManifest.js +1 -0
- dividendcase/web/_next/static/DhRV7h_G5oje4BFbueGOW/_ssgManifest.js +1 -0
- dividendcase/web/_next/static/chunks/0c87cux8r1ss5.js +1 -0
- dividendcase/web/_next/static/chunks/0cev4x3kzarbu.js +2 -0
- dividendcase/web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- dividendcase/web/_next/static/chunks/0g-bslilw6tiu.js +1 -0
- dividendcase/web/_next/static/chunks/0izc_4m5nyqrd.js +1 -0
- dividendcase/web/_next/static/chunks/0lfebwmsk92ym.js +1 -0
- dividendcase/web/_next/static/chunks/0nmuijbkm3ipd.js +1 -0
- dividendcase/web/_next/static/chunks/0ozczpntpufql.js +1 -0
- dividendcase/web/_next/static/chunks/1-ift1mtsuqvh.js +1 -0
- dividendcase/web/_next/static/chunks/10oy-ed_zrrd9.js +1 -0
- dividendcase/web/_next/static/chunks/13-u82nfffhrp.js +1 -0
- dividendcase/web/_next/static/chunks/138wmfea-yk08.js +22 -0
- dividendcase/web/_next/static/chunks/14jt62gawmtd9.js +1 -0
- dividendcase/web/_next/static/chunks/15m8s8ua7v57_.js +1 -0
- dividendcase/web/_next/static/chunks/193r1px8kfm06.js +1 -0
- dividendcase/web/_next/static/chunks/1l-w55cdq629z.js +1 -0
- dividendcase/web/_next/static/chunks/1q2bxv0xj7vse.js +1 -0
- dividendcase/web/_next/static/chunks/1s5tue3q2i-8b.js +1 -0
- dividendcase/web/_next/static/chunks/1xrz4-jij46i_.js +1 -0
- dividendcase/web/_next/static/chunks/24f36pdyspt8c.js +1 -0
- dividendcase/web/_next/static/chunks/2aqpz6z087bo4.js +1 -0
- dividendcase/web/_next/static/chunks/2ft48jvqut7tm.js +1 -0
- dividendcase/web/_next/static/chunks/2i51e627rllld.js +1 -0
- dividendcase/web/_next/static/chunks/2o72ftwupypgx.js +1 -0
- dividendcase/web/_next/static/chunks/2vq-8ocdpjh4a.css +4 -0
- dividendcase/web/_next/static/chunks/33-cg7-y-5zm5.js +1 -0
- dividendcase/web/_next/static/chunks/3cju952ndgmmn.js +1 -0
- dividendcase/web/_next/static/chunks/3il2yixgws-43.js +1 -0
- dividendcase/web/_next/static/chunks/3mc6dra1m0098.js +31 -0
- dividendcase/web/_next/static/chunks/431n5e16l34_0.js +1 -0
- dividendcase/web/_next/static/chunks/453cd1tlxz5wi.js +1 -0
- dividendcase/web/_next/static/chunks/turbopack-1che7mqzsn0nh.js +1 -0
- dividendcase/web/_next/static/media/4fa387ec64143e14-s.2tuy5pz7dlieh.woff2 +0 -0
- dividendcase/web/_next/static/media/53b9e256198e5412-s.390ncx5urfkfu.woff2 +0 -0
- dividendcase/web/_next/static/media/5ce348bf30bf5439-s.31988l_ccedte.woff2 +0 -0
- dividendcase/web/_next/static/media/6306c77e7c8268e4-s.2dbetqa9o8jxf.woff2 +0 -0
- dividendcase/web/_next/static/media/7178b3e590c64307-s.21jp631_3pja2.woff2 +0 -0
- dividendcase/web/_next/static/media/797e433ab948586e-s.p.0r6juujl39pe6.woff2 +0 -0
- dividendcase/web/_next/static/media/7d817b4c03b0c5f1-s.1uyisp29ctx0d.woff2 +0 -0
- dividendcase/web/_next/static/media/7ebf22b5a21034f8-s.p.3j3877k49yy0l.woff2 +0 -0
- dividendcase/web/_next/static/media/8a480f0b521d4e75-s.1qq4vpdcun5oj.woff2 +0 -0
- dividendcase/web/_next/static/media/bbc41e54d2fcbd21-s.1rgnod-3esatf.woff2 +0 -0
- dividendcase/web/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2 +0 -0
- dividendcase/web/_next/static/media/fc0a7394b875fa43-s.2ytmazkdrg7t9.woff2 +0 -0
- dividendcase/web/_next/static/media/fef07dbb0973bf53-s.3p2_lha1f2xer.woff2 +0 -0
- dividendcase/web/_next/static/media/icon.0kxjuvpgwz_c6.svg +85 -0
- dividendcase/web/_not-found/__next._full.txt +16 -0
- dividendcase/web/_not-found/__next._not-found.__PAGE__.txt +22 -0
- dividendcase/web/_not-found/__next._tree.txt +2 -0
- dividendcase/web/_not-found/index.html +1 -0
- dividendcase/web/_not-found/index.txt +16 -0
- dividendcase/web/dashboard/__next._full.txt +24 -0
- dividendcase/web/dashboard/__next._tree.txt +5 -0
- dividendcase/web/dashboard/__next.dashboard.__PAGE__.txt +27 -0
- dividendcase/web/dashboard/about/__next._full.txt +26 -0
- dividendcase/web/dashboard/about/__next._tree.txt +5 -0
- dividendcase/web/dashboard/about/__next.dashboard.about.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/about/index.html +1 -0
- dividendcase/web/dashboard/about/index.txt +26 -0
- dividendcase/web/dashboard/calendar/__next._full.txt +26 -0
- dividendcase/web/dashboard/calendar/__next._tree.txt +5 -0
- dividendcase/web/dashboard/calendar/__next.dashboard.calendar.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/calendar/index.html +1 -0
- dividendcase/web/dashboard/calendar/index.txt +26 -0
- dividendcase/web/dashboard/compare/__next._full.txt +26 -0
- dividendcase/web/dashboard/compare/__next._tree.txt +5 -0
- dividendcase/web/dashboard/compare/__next.dashboard.compare.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/compare/index.html +1 -0
- dividendcase/web/dashboard/compare/index.txt +26 -0
- dividendcase/web/dashboard/data/__next._full.txt +23 -0
- dividendcase/web/dashboard/data/__next._tree.txt +5 -0
- dividendcase/web/dashboard/data/__next.dashboard.data.__PAGE__.txt +27 -0
- dividendcase/web/dashboard/data/index.html +1 -0
- dividendcase/web/dashboard/data/index.txt +23 -0
- dividendcase/web/dashboard/drip/__next._full.txt +26 -0
- dividendcase/web/dashboard/drip/__next._tree.txt +5 -0
- dividendcase/web/dashboard/drip/__next.dashboard.drip.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/drip/index.html +1 -0
- dividendcase/web/dashboard/drip/index.txt +26 -0
- dividendcase/web/dashboard/index.html +1 -0
- dividendcase/web/dashboard/index.txt +24 -0
- dividendcase/web/dashboard/investments/__next._full.txt +26 -0
- dividendcase/web/dashboard/investments/__next._tree.txt +5 -0
- dividendcase/web/dashboard/investments/__next.dashboard.investments.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/investments/index.html +1 -0
- dividendcase/web/dashboard/investments/index.txt +26 -0
- dividendcase/web/dashboard/investments/portfolio/__next._full.txt +26 -0
- dividendcase/web/dashboard/investments/portfolio/__next._tree.txt +5 -0
- dividendcase/web/dashboard/investments/portfolio/__next.dashboard.investments.portfolio.__PAGE__.txt +31 -0
- dividendcase/web/dashboard/investments/portfolio/index.html +1 -0
- dividendcase/web/dashboard/investments/portfolio/index.txt +26 -0
- dividendcase/web/dashboard/screener/__next._full.txt +26 -0
- dividendcase/web/dashboard/screener/__next._tree.txt +5 -0
- dividendcase/web/dashboard/screener/__next.dashboard.screener.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/screener/index.html +1 -0
- dividendcase/web/dashboard/screener/index.txt +26 -0
- dividendcase/web/dashboard/settings/__next._full.txt +26 -0
- dividendcase/web/dashboard/settings/__next._tree.txt +5 -0
- dividendcase/web/dashboard/settings/__next.dashboard.settings.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/settings/index.html +1 -0
- dividendcase/web/dashboard/settings/index.txt +26 -0
- dividendcase/web/dashboard/stock/__next._full.txt +26 -0
- dividendcase/web/dashboard/stock/__next._tree.txt +5 -0
- dividendcase/web/dashboard/stock/__next.dashboard.stock.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/stock/index.html +1 -0
- dividendcase/web/dashboard/stock/index.txt +26 -0
- dividendcase/web/dashboard/watchlist/__next._full.txt +26 -0
- dividendcase/web/dashboard/watchlist/__next._tree.txt +5 -0
- dividendcase/web/dashboard/watchlist/__next.dashboard.watchlist.__PAGE__.txt +30 -0
- dividendcase/web/dashboard/watchlist/index.html +1 -0
- dividendcase/web/dashboard/watchlist/index.txt +26 -0
- dividendcase/web/icon.svg +85 -0
- dividendcase/web/index.html +1 -0
- dividendcase/web/index.txt +21 -0
- dividendcase/web/logo-full.svg +105 -0
- dividendcase/web/logo-icon.svg +85 -0
- dividendcase/web/tickers.json +1 -0
- dividendcase-0.1.0.dist-info/METADATA +172 -0
- dividendcase-0.1.0.dist-info/RECORD +188 -0
- dividendcase-0.1.0.dist-info/WHEEL +4 -0
- dividendcase-0.1.0.dist-info/entry_points.txt +2 -0
- dividendcase-0.1.0.dist-info/licenses/LICENSE +662 -0
dividendcase/__init__.py
ADDED
|
@@ -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"
|
dividendcase/__main__.py
ADDED
|
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
|
+
)
|