├── src ├── actuarialmath.egg-info │ ├── dependency_links.txt │ ├── top_level.txt │ ├── SOURCES.txt │ └── PKG-INFO └── actuarialmath │ ├── __init__.py │ ├── lifetime.py │ ├── extrarisk.py │ ├── actuarial.py │ ├── interest.py │ ├── life.py │ ├── sult.py │ ├── woolhouse.py │ ├── survival.py │ ├── constantforce.py │ ├── premiums.py │ ├── fractional.py │ ├── udd.py │ ├── reserves.py │ ├── mortalitylaws.py │ ├── lifetable.py │ └── mthly.py ├── FAM-L.png ├── requirements.txt ├── docs ├── source │ ├── _templates │ │ ├── modules.rst │ │ ├── actuarialmath.udd.rst │ │ ├── actuarialmath.life.rst │ │ ├── actuarialmath.sult.rst │ │ ├── actuarialmath.mthly.rst │ │ ├── actuarialmath.annuity.rst │ │ ├── actuarialmath.interest.rst │ │ ├── actuarialmath.lifetime.rst │ │ ├── actuarialmath.premiums.rst │ │ ├── actuarialmath.reserves.rst │ │ ├── actuarialmath.survival.rst │ │ ├── actuarialmath.actuarial.rst │ │ ├── actuarialmath.extrarisk.rst │ │ ├── actuarialmath.insurance.rst │ │ ├── actuarialmath.lifetable.rst │ │ ├── actuarialmath.recursion.rst │ │ ├── actuarialmath.woolhouse.rst │ │ ├── actuarialmath.fractional.rst │ │ ├── actuarialmath.selectlife.rst │ │ ├── actuarialmath.policyvalues.rst │ │ ├── actuarialmath.constantforce.rst │ │ ├── actuarialmath.mortalitylaws.rst │ │ ├── actuarialmath.rst │ │ ├── conf.py │ │ └── index.rst │ ├── actuarialmath.udd.rst │ ├── actuarialmath.life.rst │ ├── actuarialmath.sult.rst │ ├── actuarialmath.mthly.rst │ ├── actuarialmath.annuity.rst │ ├── actuarialmath.interest.rst │ ├── actuarialmath.lifetime.rst │ ├── actuarialmath.premiums.rst │ ├── actuarialmath.reserves.rst │ ├── actuarialmath.survival.rst │ ├── actuarialmath.actuarial.rst │ ├── actuarialmath.extrarisk.rst │ ├── actuarialmath.insurance.rst │ ├── actuarialmath.lifetable.rst │ ├── actuarialmath.recursion.rst │ ├── actuarialmath.woolhouse.rst │ ├── actuarialmath.fractional.rst │ ├── actuarialmath.selectlife.rst │ ├── actuarialmath.constantforce.rst │ ├── actuarialmath.mortalitylaws.rst │ ├── actuarialmath.policyvalues.rst │ ├── conf.py │ └── index.rst ├── Makefile ├── make.bat └── requirements.txt ├── changelog.md ├── .readthedocs.yaml ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tests ├── fix.py └── test_changes.py ├── pyproject.toml ├── LICENSE ├── .gitignore ├── index.rst └── README.md /src/actuarialmath.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/actuarialmath.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | actuarialmath 2 | -------------------------------------------------------------------------------- /FAM-L.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terence-lim/actuarialmath/HEAD/FAM-L.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==3.7.1 2 | matplotlib-inline==0.1.6 3 | numpy==1.24.3 4 | pandas==2.0.2 5 | scipy==1.10.1 6 | -------------------------------------------------------------------------------- /docs/source/_templates/modules.rst: -------------------------------------------------------------------------------- 1 | actuarialmath 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | actuarialmath 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.udd.rst: -------------------------------------------------------------------------------- 1 | udd 2 | === 3 | 4 | .. automodule:: actuarialmath.udd 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.life.rst: -------------------------------------------------------------------------------- 1 | life 2 | ==== 3 | 4 | .. automodule:: actuarialmath.life 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.sult.rst: -------------------------------------------------------------------------------- 1 | sult 2 | ==== 3 | 4 | .. automodule:: actuarialmath.sult 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.mthly.rst: -------------------------------------------------------------------------------- 1 | mthly 2 | ===== 3 | 4 | .. automodule:: actuarialmath.mthly 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.annuity.rst: -------------------------------------------------------------------------------- 1 | annuity 2 | ======= 3 | 4 | .. automodule:: actuarialmath.annuity 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.interest.rst: -------------------------------------------------------------------------------- 1 | interest 2 | ======== 3 | 4 | .. automodule:: actuarialmath.interest 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.lifetime.rst: -------------------------------------------------------------------------------- 1 | lifetime 2 | ======== 3 | 4 | .. automodule:: actuarialmath.lifetime 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.premiums.rst: -------------------------------------------------------------------------------- 1 | premiums 2 | ======== 3 | 4 | .. automodule:: actuarialmath.premiums 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.reserves.rst: -------------------------------------------------------------------------------- 1 | reserves 2 | ======== 3 | 4 | .. automodule:: actuarialmath.reserves 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.survival.rst: -------------------------------------------------------------------------------- 1 | survival 2 | ======== 3 | 4 | .. automodule:: actuarialmath.survival 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.actuarial.rst: -------------------------------------------------------------------------------- 1 | actuarial 2 | ========= 3 | 4 | .. automodule:: actuarialmath.actuarial 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.extrarisk.rst: -------------------------------------------------------------------------------- 1 | extrarisk 2 | ========= 3 | 4 | .. automodule:: actuarialmath.extrarisk 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.insurance.rst: -------------------------------------------------------------------------------- 1 | insurance 2 | ========= 3 | 4 | .. automodule:: actuarialmath.insurance 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.lifetable.rst: -------------------------------------------------------------------------------- 1 | lifetable 2 | ========= 3 | 4 | .. automodule:: actuarialmath.lifetable 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.recursion.rst: -------------------------------------------------------------------------------- 1 | recursion 2 | ========= 3 | 4 | .. automodule:: actuarialmath.recursion 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.woolhouse.rst: -------------------------------------------------------------------------------- 1 | woolhouse 2 | ========= 3 | 4 | .. automodule:: actuarialmath.woolhouse 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.fractional.rst: -------------------------------------------------------------------------------- 1 | fractional 2 | ========== 3 | 4 | .. automodule:: actuarialmath.fractional 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.selectlife.rst: -------------------------------------------------------------------------------- 1 | selectlife 2 | ========== 3 | 4 | .. automodule:: actuarialmath.selectlife 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.constantforce.rst: -------------------------------------------------------------------------------- 1 | constantforce 2 | ============= 3 | 4 | .. automodule:: actuarialmath.constantforce 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.mortalitylaws.rst: -------------------------------------------------------------------------------- 1 | mortalitylaws 2 | ============= 3 | 4 | .. automodule:: actuarialmath.mortalitylaws 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/actuarialmath.policyvalues.rst: -------------------------------------------------------------------------------- 1 | policyvalues 2 | ============ 3 | 4 | .. automodule:: actuarialmath.policyvalues 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.udd.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.udd module 2 | ======================== 3 | 4 | .. automodule:: actuarialmath.udd 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.life.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.life module 2 | ========================= 3 | 4 | .. automodule:: actuarialmath.life 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.sult.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.sult module 2 | ========================= 3 | 4 | .. automodule:: actuarialmath.sult 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.mthly.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.mthly module 2 | ========================== 3 | 4 | .. automodule:: actuarialmath.mthly 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.annuity.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.annuity module 2 | ============================ 3 | 4 | .. automodule:: actuarialmath.annuity 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.interest.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.interest module 2 | ============================= 3 | 4 | .. automodule:: actuarialmath.interest 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.lifetime.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.lifetime module 2 | ============================= 3 | 4 | .. automodule:: actuarialmath.lifetime 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.premiums.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.premiums module 2 | ============================= 3 | 4 | .. automodule:: actuarialmath.premiums 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.reserves.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.reserves module 2 | ============================= 3 | 4 | .. automodule:: actuarialmath.reserves 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.survival.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.survival module 2 | ============================= 3 | 4 | .. automodule:: actuarialmath.survival 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.actuarial.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.actuarial module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.actuarial 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.extrarisk.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.extrarisk module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.extrarisk 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.insurance.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.insurance module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.insurance 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.lifetable.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.lifetable module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.lifetable 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.recursion.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.recursion module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.recursion 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.woolhouse.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.woolhouse module 2 | ============================== 3 | 4 | .. automodule:: actuarialmath.woolhouse 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.fractional.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.fractional module 2 | =============================== 3 | 4 | .. automodule:: actuarialmath.fractional 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.selectlife.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.selectlife module 2 | =============================== 3 | 4 | .. automodule:: actuarialmath.selectlife 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.policyvalues.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.policyvalues module 2 | ================================= 3 | 4 | .. automodule:: actuarialmath.policyvalues 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.constantforce.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.constantforce module 2 | ================================== 3 | 4 | .. automodule:: actuarialmath.constantforce 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.mortalitylaws.rst: -------------------------------------------------------------------------------- 1 | actuarialmath.mortalitylaws module 2 | ================================== 3 | 4 | .. automodule:: actuarialmath.mortalitylaws 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.0.14] - 2023-06-24 6 | 7 | ### Added 8 | 9 | - downgraded numpy and pandas version dependencies to match Colab 10 | - bug fixes and improvements to blogging in Recursion module 11 | - pretty-printing of results in Latex actuarial notation for Jupyter notebook 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | # Build from the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | 13 | # Explicitly set the version of Python and its requirements 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/fix.py: -------------------------------------------------------------------------------- 1 | from actuarialmath import Recursion 2 | 3 | x = 0 4 | life = Recursion(depth=5).set_interest(i=0.06)\ 5 | .set_p(0.975, x=x)\ 6 | .set_a(152.85/56.05, x=x, t=3)\ 7 | .set_A(152.85, x=x, t=3, b=1000) 8 | p = life.p_x(x=x+2) 9 | print(0.91, p, "Q6.10") 10 | 11 | 12 | 13 | 14 | life = Recursion().set_A(0.39, x=35, t=15, endowment=1)\ 15 | .set_A(0.25, x=35, t=15)\ 16 | .set_A(0.32, x=35) 17 | A = life.whole_life_insurance(x=50) 18 | 19 | #def fun(A): return life.set_A(A, x=50).term_insurance(35, t=15) 20 | #A = life.solve(fun, target=0.25, grid=[0.35, 0.55]) 21 | print(0.5, A, "Q4.9") 22 | 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /src/actuarialmath/__init__.py: -------------------------------------------------------------------------------- 1 | from .actuarial import Actuarial 2 | from .interest import Interest 3 | from .life import Life 4 | from .survival import Survival 5 | from .lifetime import Lifetime 6 | from .fractional import Fractional 7 | from .insurance import Insurance 8 | from .annuity import Annuity 9 | from .premiums import Premiums 10 | from .policyvalues import PolicyValues, Contract 11 | from .reserves import Reserves 12 | from .recursion import Recursion 13 | from .lifetable import LifeTable 14 | from .sult import SULT 15 | from .selectlife import SelectLife 16 | from .mortalitylaws import MortalityLaws, Beta, Uniform, Makeham, Gompertz 17 | from .constantforce import ConstantForce 18 | from .extrarisk import ExtraRisk 19 | from .mthly import Mthly 20 | from .udd import UDD 21 | from .woolhouse import Woolhouse 22 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Defining the exact version will make sure things don't break 2 | sphinx==6.2.1 3 | sphinx_rtd_theme==1.2.1 4 | 5 | asttokens==2.2.1 6 | backcall==0.2.0 7 | contourpy==1.0.7 8 | cycler==0.11.0 9 | decorator==5.1.1 10 | exceptiongroup==1.1.1 11 | executing==1.2.0 12 | fonttools==4.39.4 13 | iniconfig==2.0.0 14 | ipython==8.13.2 15 | jedi==0.18.2 16 | kiwisolver==1.4.4 17 | matplotlib==3.7.1 18 | matplotlib-inline==0.1.6 19 | numpy==1.24.3 20 | packaging==23.1 21 | pandas==2.0.2 22 | parso==0.8.3 23 | pexpect==4.8.0 24 | pickleshare==0.7.5 25 | Pillow==9.5.0 26 | pluggy==1.0.0 27 | prompt-toolkit==3.0.38 28 | ptyprocess==0.7.0 29 | pure-eval==0.2.2 30 | Pygments==2.15.1 31 | pyparsing==3.0.9 32 | PyQt6==6.5.0 33 | PyQt6-Qt6==6.5.1 34 | PyQt6-sip==13.5.1 35 | pytest==7.3.1 36 | python-dateutil==2.8.2 37 | pytz==2023.3 38 | scipy==1.10.1 39 | six==1.16.0 40 | stack-data==0.6.2 41 | tomli==2.0.1 42 | traitlets==5.9.0 43 | tzdata==2023.3 44 | wcwidth==0.2.6 45 | -------------------------------------------------------------------------------- /docs/source/_templates/actuarialmath.rst: -------------------------------------------------------------------------------- 1 | actuarialmath package 2 | ===================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | actuarialmath.actuarial 11 | actuarialmath.annuity 12 | actuarialmath.constantforce 13 | actuarialmath.extrarisk 14 | actuarialmath.fractional 15 | actuarialmath.insurance 16 | actuarialmath.interest 17 | actuarialmath.life 18 | actuarialmath.lifetable 19 | actuarialmath.lifetime 20 | actuarialmath.mortalitylaws 21 | actuarialmath.mthly 22 | actuarialmath.policyvalues 23 | actuarialmath.premiums 24 | actuarialmath.recursion 25 | actuarialmath.reserves 26 | actuarialmath.selectlife 27 | actuarialmath.sult 28 | actuarialmath.survival 29 | actuarialmath.udd 30 | actuarialmath.woolhouse 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: actuarialmath 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "actuarialmath" 7 | #version = "0.1.1" 8 | version = "1.0.1" 9 | authors = [ 10 | { name="Terence Lim"}, 11 | ] 12 | description = "A package for solving actuarial math and life contingent risks" 13 | readme = "index.rst" 14 | requires-python = ">=3.10" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | 21 | #dependencies = [ 22 | # "matplotlib>=3.7.1", 23 | # "numpy>=1.24.3", 24 | # "pandas>=2.0.2", 25 | # "scipy>=1.10.1", 26 | #] 27 | 28 | dependencies = [ 29 | "matplotlib>=3.7.1", 30 | "numpy>=1.22.4", 31 | "pandas>=1.5.3", 32 | "scipy>=1.10.1", 33 | ] 34 | 35 | [project.urls] 36 | "Homepage" = "https://github.com/terence-lim/actuarialmath" 37 | "Bug Tracker" = "https://github.com/terence-lim/actuarialmath/issues" 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/actuarialmath.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE 2 | README.md 3 | index.rst 4 | pyproject.toml 5 | src/actuarialmath/__init__.py 6 | src/actuarialmath/actuarial.py 7 | src/actuarialmath/annuity.py 8 | src/actuarialmath/constantforce.py 9 | src/actuarialmath/extrarisk.py 10 | src/actuarialmath/fractional.py 11 | src/actuarialmath/insurance.py 12 | src/actuarialmath/interest.py 13 | src/actuarialmath/life.py 14 | src/actuarialmath/lifetable.py 15 | src/actuarialmath/lifetime.py 16 | src/actuarialmath/mortalitylaws.py 17 | src/actuarialmath/mthly.py 18 | src/actuarialmath/policyvalues.py 19 | src/actuarialmath/premiums.py 20 | src/actuarialmath/recursion.py 21 | src/actuarialmath/reserves.py 22 | src/actuarialmath/selectlife.py 23 | src/actuarialmath/sult.py 24 | src/actuarialmath/survival.py 25 | src/actuarialmath/udd.py 26 | src/actuarialmath/woolhouse.py 27 | src/actuarialmath.egg-info/PKG-INFO 28 | src/actuarialmath.egg-info/SOURCES.txt 29 | src/actuarialmath.egg-info/dependency_links.txt 30 | src/actuarialmath.egg-info/requires.txt 31 | src/actuarialmath.egg-info/top_level.txt 32 | tests/test_changes.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Terence Lim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/_templates/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'actuarialmath' 10 | copyright = '2023, Terence Lim' 11 | author = 'Terence Lim' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | 'sphinx.ext.autodoc', 18 | 'sphinx.ext.napoleon', 19 | 'sphinx.ext.viewcode', 20 | ] 21 | 22 | templates_path = ['_templates'] 23 | exclude_patterns = [] 24 | 25 | autodoc_default_options = { 26 | 'special-members': '__getitem__, __call__, __str__', #'__init__, __call__', 27 | } 28 | 29 | 30 | # -- Options for HTML output ------------------------------------------------- 31 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 32 | 33 | html_theme = 'sphinx_rtd_theme' 34 | html_static_path = ['_static'] 35 | 36 | # Sort members by type 37 | autodoc_member_order = 'bysource' #'groupwise' 38 | 39 | import sys 40 | sys.path.insert(0, '../src') 41 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'actuarialmath' 10 | copyright = '2023, Terence Lim' 11 | author = 'Terence Lim' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | 'sphinx.ext.autodoc', 18 | 'sphinx.ext.napoleon', 19 | 'sphinx.ext.viewcode', 20 | ] 21 | 22 | templates_path = ['_templates'] 23 | exclude_patterns = [] 24 | 25 | autodoc_default_options = { 26 | 'special-members': '__getitem__, __call__', #'__init__, __call__, __str__', 27 | } 28 | 29 | 30 | # -- Options for HTML output ------------------------------------------------- 31 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 32 | 33 | html_theme = 'sphinx_rtd_theme' 34 | html_static_path = ['_static'] 35 | 36 | # Sort members by type 37 | autodoc_member_order = 'bysource' #'groupwise' 38 | 39 | import sys 40 | import os 41 | sys.path.insert(0, os.path.abspath('../..')) 42 | sys.path.insert(0, os.path.abspath('../../src')) 43 | -------------------------------------------------------------------------------- /tests/test_changes.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pandas as pd 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | from actuarialmath import Interest 6 | from actuarialmath import Life 7 | from actuarialmath import Survival 8 | from actuarialmath import Lifetime 9 | from actuarialmath import Fractional 10 | from actuarialmath import Insurance 11 | from actuarialmath import Annuity 12 | from actuarialmath import Premiums 13 | from actuarialmath import PolicyValues, Contract 14 | from actuarialmath import Reserves 15 | from actuarialmath import Recursion 16 | from actuarialmath import LifeTable 17 | from actuarialmath import SULT 18 | from actuarialmath import SelectLife 19 | from actuarialmath import MortalityLaws, Beta, Uniform, Makeham, Gompertz 20 | from actuarialmath import ConstantForce 21 | from actuarialmath import ExtraRisk 22 | from actuarialmath import Mthly 23 | from actuarialmath import UDD 24 | from actuarialmath import Woolhouse 25 | 26 | # June 8, 2023 27 | """1.1102230246251565e-16 28 | 66.41315159665598 29 | 0.00020973561925718975 30 | 65.91315159665601""" 31 | 32 | life = SULT().set_interest(i=0) 33 | for discrete in [True, False]: # divide by zero 34 | for variance in [True, False]: 35 | print(life.whole_life_annuity(20, discrete=discrete, variance=variance)) 36 | print() 37 | 38 | """2.220446049250313e-16 39 | 26.30938051545656 40 | 0.00011454063198124143 41 | 26.148627042747307""" 42 | 43 | for discrete in [True, False]: # divide by zero 44 | for variance in [True, False]: 45 | print(life.temporary_annuity(60, t=30, discrete=discrete, 46 | variance=variance)) 47 | print() 48 | 49 | """0 False inf 50 | 0 False 0.0 51 | 0 True 111.0 52 | 0 True 0.0 53 | 1 False 1.0 54 | 1 False 1.0 55 | 1 True 1.5819767068693262 56 | 1 True 0.9999999999999999 57 | 9034.654127845053""" 58 | 59 | for mu in [0,1]: 60 | for discrete in [False, True]: 61 | life = ConstantForce(mu=mu) 62 | print(mu, discrete, life.whole_life_annuity(20, discrete=discrete)) 63 | print(mu, discrete, life.whole_life_insurance(20, discrete=discrete)) 64 | 65 | b = 10000 # premiums=0 after t=10 66 | L = SULT().set_interest(i=0.05).whole_life_insurance(x=35, b=b) 67 | V = SULT().set_interest(i=0).whole_life_insurance(x=35, b=b) 68 | print(V-L) 69 | 70 | -------------------------------------------------------------------------------- /src/actuarialmath/lifetime.py: -------------------------------------------------------------------------------- 1 | """Future lifetimes - Computes expectations and moments of future lifetime 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | import math 6 | from actuarialmath import Survival 7 | 8 | class Lifetime(Survival): 9 | """Computes expected moments of future lifetime""" 10 | 11 | def __init__(self, **kwargs): 12 | super().__init__(**kwargs) 13 | 14 | 15 | def e_x(self, x: int, s: int = 0, t: int = Survival.WHOLE, 16 | curtate: bool = True, moment: int = 1) -> float: 17 | """Compute curtate or complete expectations and moments of life 18 | 19 | Args: 20 | x : age of selection 21 | s : years after selection 22 | t : limited at t years 23 | curtate : whether curtate (True) or complete (False) expectations 24 | moment : whether to compute first (1) or second (2) moment 25 | 26 | Examples: 27 | >>> def l(x, s): return 0. if (x+s) >= 100 else 1 - ((x + s)**2) / 10000. 28 | >>> print(Lifetime().set_survival(l=l).e_x(75, t=10, curtate=False)) 29 | """ 30 | assert moment in [1, 2, self.VARIANCE], "moment must be 1, 2 or -2" 31 | assert x >= 0, "x must be non-negative" 32 | assert s >= 0, "s must be non-negative" 33 | 34 | if t == 1 and curtate: # shortcut for e_x:1 35 | return self.p_x(x, s=s, t=1) 36 | 37 | t = self.max_term(x+s, t) # length of term must be bounded by max age 38 | if curtate: 39 | if moment == 1: 40 | return sum([self.p_x(x, s=s, t=k) for k in range(1, round(t+1))]) 41 | e2 = sum([((2 * k) - 1) * self.p_x(x, s=s, t=k) 42 | for k in range(1, round(t+1))]) 43 | else: 44 | if moment == 1: 45 | return self.integral(lambda t: self.S(x, s, t), 0., float(t)) 46 | e2 = self.integral(lambda t: 2 * t * self.S(x, s, t), 0., float(t)) 47 | 48 | if moment == self.VARIANCE: # variance is E[T_x^2] - E[T_x]^2 49 | return e2 - self.e_x(x, s=s, t=t, curtate=curtate, moment=1)**2 50 | return e2 # return second moment 51 | 52 | 53 | if __name__ == "__main__": 54 | print("SOA Question 2.4: (E) 8.2") 55 | def l(x, s): return 0. if (x+s) >= 100 else 1 - ((x + s)**2) / 10000. 56 | e = Lifetime().set_survival(l=l).e_x(75, t=10, curtate=False) 57 | print(e) 58 | 59 | print("SOA Question 2.1: (B) 2.5") 60 | def fun(omega): # Solve first for omega, given mu_65 = 1/180 61 | return Lifetime().set_survival(l=lambda x,s: (1-(x+s)/omega)**0.25, 62 | maxage=omega).mu_x(65) 63 | omega = int(Lifetime.solve(fun, target=1/180, grid=100)) # solve for omega 64 | e = Lifetime().set_survival(l=lambda x,s: (1 - (x+s)/omega)**0.25, 65 | maxage=omega).e_x(106) 66 | print(e) 67 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # emacs backup 7 | *~ 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /index.rst: -------------------------------------------------------------------------------- 1 | actuarialmath - Solve Life Contingent Risks with Python 2 | ======================================================= 3 | 4 | This Python package implements fundamental methods for modeling life contingent risks, and closely follows the coverage of traditional topics in actuarial exams and standard texts such as the "Fundamentals of Actuarial Math - Long-term" exam syllabus by the Society of Actuaries, and "Actuarial Mathematics for Life Contingent Risks" by Dickson, Hardy and Waters. 5 | 6 | Overview 7 | -------- 8 | 9 | The package comprises three sets of classes, which: 10 | 11 | 1. Implement general actuarial methods 12 | 13 | - Basic interest theory and probability laws 14 | 15 | - Survival functions, expected future lifetimes and fractional ages 16 | 17 | - Insurance, annuity, premiums, policy values, and reserves calculations 18 | 19 | 20 | 2. Adjust results for 21 | 22 | - Extra mortality risks 23 | 24 | - 1/mthly payment frequency using UDD or Woolhouse approaches 25 | 26 | 3. Specify and load a particular form of assumptions 27 | 28 | - Recursion inputs 29 | 30 | - Life table, select life table, or standard ultimate life table 31 | 32 | - Mortality laws, such as constant force of maturity, beta and uniform distributions, or Makeham's and Gompertz's laws 33 | 34 | 35 | Quick Start 36 | ----------- 37 | 38 | 1. ``pip install actuarialmath`` 39 | 40 | - also requires `numpy`, `scipy`, `matplotlib` and `pandas`. 41 | 42 | 2. Start Python (version >= 3.10) or Jupyter-notebook 43 | 44 | - Select a suitable subclass to initialize with your actuarial assumptions, such as `MortalityLaws` (or a special law like `ConstantForce`), `LifeTable`, `SULT`, `SelectLife` or `Recursion`. 45 | 46 | - Call appropriate methods to compute intermediate or final results, or to `solve` parameter values implicitly. 47 | 48 | - Adjust the answers with `ExtraRisk` or `Mthly` (or its `UDD` or `Woolhouse`) classes. 49 | 50 | Examples 51 | -------- 52 | 53 | :: 54 | 55 | # SOA FAM-L sample question 5.7 56 | from actuarialmath import Recursion, Woolhouse 57 | # initialize Recursion class with actuarial inputs 58 | life = Recursion().set_interest(i=0.04)\ 59 | .set_A(0.188, x=35)\ 60 | .set_A(0.498, x=65)\ 61 | .set_p(0.883, x=35, t=30) 62 | # modfy the standard results with Woolhouse mthly approximation 63 | mthly = Woolhouse(m=2, life=life, three_term=False) 64 | # compute the desired temporary annuity value 65 | print(1000 * mthly.temporary_annuity(35, t=30)) # solution = 17376.7 66 | 67 | :: 68 | 69 | # SOA FAM-L sample question 7.20 70 | from actuarialmath import SULT, Contract 71 | life = SULT() 72 | # compute the required FPT policy value 73 | S = life.FPT_policy_value(35, t=1, b=1000) # is always 0 in year 1! 74 | # input the given policy contract terms 75 | contract = Contract(benefit=1000, 76 | initial_premium=.3, 77 | initial_policy=300, 78 | renewal_premium=.04, 79 | renewal_policy=30) 80 | # compute gross premium using the equivalence principle 81 | G = life.gross_premium(A=life.whole_life_insurance(35), **contract.premium_terms) 82 | # compute the required policy value 83 | R = life.gross_policy_value(35, t=1, contract=contract.set_contract(premium=G)) 84 | print(R-S) # solution = -277.19 85 | 86 | Resources 87 | --------- 88 | 89 | 1. `Jupyter notebook `_ or `run in Colab `_, to solve all sample SOA FAM-L exam questions 90 | 91 | 2. `User Guide `_, or `download pdf `_ 92 | 93 | 3. `API reference `_ 94 | 95 | 4. `Github repo `_ and `issues `_ 96 | 97 | -------------------------------------------------------------------------------- /docs/source/_templates/index.rst: -------------------------------------------------------------------------------- 1 | actuarialmath - Life Contingent Risks with Python 2 | ================================================= 3 | 4 | This package implements fundamental methods for modeling life contingent risks, and closely follows traditional topics covered in actuarial exams and standard texts such as the "Fundamentals of Actuarial Math - Long-term" exam syllabus by the Society of Actuaries, and "Actuarial Mathematics for Life Contingent Risks" by Dickson, Hardy and Waters. 5 | 6 | Overview 7 | -------- 8 | 9 | The package comprises three sets of classes, which: 10 | 11 | 1. Implement general actuarial concepts 12 | 13 | a. Basic interest theory and probability laws 14 | b. Survival functions, future lifetime and fractional ages 15 | c. Insurance, annuity, premiums, policy values, and reserves calculations 16 | 17 | 2. Adjust results for 18 | 19 | a. Extra risks 20 | b. 1/mthly payments using UDD or Woolhouse approaches 21 | 22 | 3. Specify and load a particular form of assumptions 23 | 24 | a. Life table, select life table, or standard ultimate life table 25 | b. Mortality laws, such as constant force of maturity, beta and uniform distributions, or Makeham's and Gompertz's laws 26 | c. Recursion inputs 27 | 28 | Quick Start 29 | ----------- 30 | 31 | 1. `pip install actuarialmath` 32 | 2. Select a suitable subclass to initialize with your actuarial assumptions, such as `MortalityLaws` (or a special law like `ConstantForce`), `LifeTable`, `SULT`, `SelectTable` or `Recursion`. 33 | 3. Call appropriate methods to compute intermediate or final results, or to `solve` parameter values implicitly. 34 | 4. If needed, adjust the answers with `ExtraRisk` or `Mthly` (or its `UDD` or `Woolhouse`) classes. 35 | 36 | Examples 37 | -------- 38 | 39 | 40 | :: 41 | 42 | # SOA FAM-L sample question 5.7 43 | from actuarialmath.recursion import Recursion 44 | from actuarialmath.woolhouse import Woolhouse 45 | # initialize Recursion class with actuarial inputs 46 | life = Recursion().set_interest(i=0.04)\\ 47 | .set_A(0.188, x=35)\\ 48 | .set_A(0.498, x=65)\\ 49 | .set_p(0.883, x=35, t=30) 50 | # modfy the standard results with Woolhouse mthly approximation 51 | mthly = Woolhouse(m=2, life=life, three_term=False) 52 | # compute the desired temporary annuity value 53 | print(1000 * mthly.temporary_annuity(35, t=30)) # solution = 17376.7 54 | 55 | :: 56 | 57 | # SOA FAM-L sample question 7.20 58 | from actuarialmath.sult import SULT # use Standard Ultimate Life Table 59 | from actuarialmath.policyvalues import Contract 60 | life = SULT() 61 | # compute the required FPT policy value 62 | S = life.FPT_policy_value(35, t=1, b=1000) # is always 0 in year 1! 63 | # input the given policy contract terms 64 | contract = Contract(benefit=1000, 65 | initial_premium=.3, 66 | initial_policy=300, 67 | renewal_premium=.04, 68 | renewal_policy=30) 69 | # compute gross premium using the equivalence principle 70 | G = life.gross_premium(A=life.whole_life_insurance(35), **contract.premium_terms) 71 | # compute the required policy value 72 | R = life.gross_policy_value(35, t=1, contract=contract.set_contract(premium=G)) 73 | print(R-S) # solution = -277.19 74 | 75 | Resources 76 | --------- 77 | 78 | 1. `Colab `_ or `Jupyter notebook `_, to solve all sample SOA FAM-L exam questions 79 | 80 | 2. `Online tutorial `_, or `download pdf `_ 81 | 82 | 3. `Code documentation `_ 83 | 84 | 4. `Github repo `_ and `issues `_ 85 | 86 | .. toctree:: 87 | :maxdepth: 4 88 | :caption: Contents: 89 | 90 | modules 91 | 92 | Indices and tables 93 | ------------------ 94 | 95 | * :ref:`genindex` 96 | * :ref:`modindex` 97 | * :ref:`search` 98 | -------------------------------------------------------------------------------- /src/actuarialmath/extrarisk.py: -------------------------------------------------------------------------------- 1 | """Extra Risk - Adjusts force of mortality, age rating or mortality rate 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | from typing import Dict 6 | import math 7 | from actuarialmath import Survival 8 | from actuarialmath import Actuarial 9 | 10 | class ExtraRisk(Actuarial): 11 | """Adjust mortality by extra risk 12 | 13 | Args: 14 | life : contains original survival and mortality rates 15 | extra : amount of extra risk to adjust 16 | risk : adjust by {"ADD_FORCE", "MULTIPLY_FORCE", "ADD_AGE", "MULTIPLY_RATE"} 17 | """ 18 | risks = ["ADD_FORCE", "MULTIPLY_FORCE", "ADD_AGE", "MULTIPLY_RATE"] 19 | 20 | def __init__(self, 21 | life: Survival, 22 | risk: str = "", 23 | extra: float = 0.) -> "ExtraRisk": 24 | """Specify type and amount of mortality adjustment to apply""" 25 | assert not risk or risk in self.risks, "risk must be one of " + str(risks) 26 | assert extra >= 0, "amount of extra risk must be non-negative" 27 | self.life = life 28 | self.extra_ = extra 29 | self.risk_ = risk 30 | 31 | def __getitem__(self, col: str) -> Dict[int, float]: 32 | """Returns survival function values adjusted by extra risk 33 | 34 | Args: 35 | col : {'p', 'q'} for one-year survival or mortality function values 36 | 37 | Returns: 38 | dict of age and survival function values adjusted by extract risk 39 | 40 | Examples: 41 | >>> life = SULT() 42 | >>> extra = ExtraRisk(life=life, extra=0.05, risk="ADD_FORCE") 43 | >>> select = SelectLife(periods=1).set_select(s=0, age_selected=True, 44 | q=extra['q']) 45 | """ 46 | f = {'q': self.q_x, 'p': self.p_x}[col[0]] 47 | return {x: f(x) for x in range(self.life._MINAGE, self.life._MAXAGE+1)} 48 | 49 | def p_x(self, x: int, s: int = 0) -> float: 50 | """Return p_[x]+s after adding or multiplying force of mortality 51 | 52 | Args: 53 | x : age of selection 54 | s : years after selection 55 | 56 | Examples: 57 | >>> life = SULT() 58 | >>> extra = ExtraRisk(life=life, extra=2, risk="MULTIPLY_FORCE") 59 | >>> print(life.p_x(45), extra.p_x(45)) 60 | """ 61 | if self.risk_ in ["MULTIPLY_RATE"]: 62 | return 1 - self.q_x(x, s=s) 63 | if self.risk_ in ["ADD_AGE"]: 64 | return self.life.p_x(x + self.extra_, s=s) 65 | p = self.life.p_x(x, s=s) 66 | if self.risk_ in ["MULTIPLY_FORCE"]: 67 | p = p**self.extra_ 68 | if self.risk_ in ["ADD_FORCE"]: 69 | p *= math.exp(-self.extra_) 70 | return p 71 | 72 | def q_x(self, x: int, s: int = 0) -> float: 73 | """Return q_[x]+s after adding age rating or multipliying mortality rate 74 | 75 | Args: 76 | x : age of selection 77 | s : years after selection 78 | """ 79 | if self.risk_ in ["ADD_FORCE", "MULTIPLY_FORCE"]: 80 | return 1 - self.p_x(x, s=s) 81 | if self.risk_ in ["ADD_AGE"]: 82 | return self.life.q_x(x + self.extra_, s=s) 83 | if self.risk_ in ["MULTIPLY_RATE"]: 84 | return self.extra_ * self.life.q_x(x, s=s) 85 | 86 | if __name__ == "__main__": 87 | from actuarialmath.selectlife import SelectLife 88 | from actuarialmath.sult import SULT 89 | 90 | print("SOA Question 5.5: (A) 1699.6") 91 | life = SULT() 92 | extra = ExtraRisk(life=life, extra=0.05, risk="ADD_FORCE") 93 | select = SelectLife(periods=1)\ 94 | .set_interest(i=.05)\ 95 | .set_select(s=0, age_selected=True, q=extra['q'])\ 96 | .set_select(s=1, age_selected=False, a=life['a'])\ 97 | .fill_table() 98 | print(100*select['a'][45][0]) 99 | print() 100 | 101 | print("SOA Question 4.19: (B) 59050") 102 | life = SULT() 103 | extra = ExtraRisk(life=life, extra=0.8, risk="MULTIPLY_RATE") 104 | select = SelectLife(periods=1)\ 105 | .set_interest(i=.05)\ 106 | .set_select(s=0, age_selected=True, q=extra['q'])\ 107 | .set_select(s=1, age_selected=False, q=life['q'])\ 108 | .fill_table() 109 | print(100000*select.whole_life_insurance(80, s=0)) 110 | print() 111 | 112 | print("Other usage examples") 113 | life = SULT() 114 | extra = ExtraRisk(life=life, extra=2, risk="MULTIPLY_FORCE") 115 | print(life.p_x(45), extra.p_x(45)) 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # USER GUIDE 2 | 3 | __actuarialmath -- Solve Life Contingent Risks with Python__ 4 | 5 | This Python package implements fundamental methods for modeling life contingent risks, and closely follows the coverage of traditional topics in actuarial exams and standard texts such as the "Fundamentals of Actuarial Math - Long-term" exam syllabus by the Society of Actuaries, and "Actuarial Mathematics for Life Contingent Risks" by Dickson, Hardy and Waters. The actuarial concepts, and corresponding Python classes, are introduced and modeled hierarchically. 6 | 7 | 8 | ![classes and concepts](FAM-L.png) 9 | 10 | 11 | ## Quick Start 12 | 13 | 1. `pip install actuarialmath` 14 | 15 | - also requires `numpy`, `scipy`, `matplotlib` and `pandas`. 16 | 17 | 18 | 2. Start Python (version >= 3.10) or Jupyter-notebook 19 | 20 | 21 | - Select a suitable subclass to initialize with your actuarial assumptions, such as `MortalityLaws` (or a special law like `ConstantForce`), `LifeTable`, `SULT`, `SelectLife` or `Recursion`. 22 | 23 | - Call appropriate methods to compute intermediate or final results, or to `solve` parameter values implicitly. 24 | 25 | - Adjust answers with `ExtraRisk` or `Mthly` (or its `UDD` or `Woolhouse`) classes 26 | 27 | 28 | ## Examples 29 | 30 | __SOA FAM-L sample question 5.7__: 31 | 32 | Given $A_{35} = 0.188$, $A_{65} = 0.498$, $S_{35}(30) = 0.883$, calculate the EPV of a temporary annuity $\ddot{a}^{(2)}_{35:\overline{30|}}$ paid half-yearly using the Woolhouse approximation. 33 | 34 | ``` 35 | from actuarialmath import Recursion, Woolhouse 36 | # initialize Recursion class with actuarial inputs 37 | life = Recursion().set_interest(i=0.04)\ 38 | .set_A(0.188, x=35)\ 39 | .set_A(0.498, x=65)\ 40 | .set_p(0.883, x=35, t=30) 41 | # modfy the standard results with Woolhouse mthly approximation 42 | mthly = Woolhouse(m=2, life=life, three_term=False) 43 | # compute the desired temporary annuity value 44 | print(1000 * mthly.temporary_annuity(35, t=30)) # solution = 17376.7 45 | ``` 46 | 47 | __SOA FAM-L sample question 7.20__: 48 | 49 | For a fully discrete whole life insurance of 1000 on (35), you are given 50 | - First year expenses are 30% of the gross premium plus 300 51 | - Renewal expenses are 4% of the gross premium plus 30 52 | - All expenses are incurred at the beginning of the policy year 53 | - Gross premiums are calculated using the equivalence principle 54 | - The gross premium policy value at the end of the first policy year is R 55 | - Using the Full Preliminary Term Method, the modified reserve at the end of the first policy year is S 56 | - Mortality follows the Standard Ultimate Life Table 57 | - _i_ = 0.05 58 | 59 | Calculate R − S 60 | ``` 61 | from actuarialmath import SULT, Contract 62 | life = SULT() 63 | # compute the required FPT policy value 64 | S = life.FPT_policy_value(35, t=1, b=1000) # is always 0 in year 1! 65 | # input the given policy contract terms 66 | contract = Contract(benefit=1000, 67 | initial_premium=.3, 68 | initial_policy=300, 69 | renewal_premium=.04, 70 | renewal_policy=30) 71 | # compute gross premium using the equivalence principle 72 | G = life.gross_premium(A=life.whole_life_insurance(35), **contract.premium_terms) 73 | # compute the required policy value 74 | R = life.gross_policy_value(35, t=1, contract=contract.set_contract(premium=G)) 75 | print(R-S) # solution = -277.19 76 | ``` 77 | 78 | 79 | ## Resources 80 | 81 | 1. [Jupyter notebook](https://terence-lim.github.io/notes/faml.ipynb), or [run in Colab](https://colab.research.google.com/github/terence-lim/terence-lim.github.io/blob/master/notes/faml.ipynb), to solve all sample SOA FAM-L exam questions 82 | 83 | 2. [User Guide](https://actuarialmath-guide.readthedocs.io/en/latest/), or [download pdf](https://terence-lim.github.io/notes/actuarialmath-guide.pdf) 84 | 85 | 3. [API reference](https://actuarialmath.readthedocs.io/en/latest/) 86 | 87 | 4. [Github repo](https://github.com/terence-lim/actuarialmath.git) and [issues](https://github.com/terence-lim/actuarialmath/issues) 88 | 89 | 90 | 91 | ## Sources 92 | 93 | - SOA FAM-L Sample Questions: [copy retrieved Aug 2022](https://terence-lim.github.io/notes/2022-10-exam-fam-l-quest.pdf) 94 | 95 | - SOA FAM-L Sample Solutions: [copy retrieved Aug 2022](https://terence-lim.github.io/notes/2022-10-exam-fam-l-sol.pdf) 96 | 97 | - Actuarial Mathematics for Life Contingent Risks, by David Dickson, Mary Hardy and Howard Waters, published by Cambridge University Press. 98 | 99 | 100 | ## Contact 101 | 102 | Github: [https://terence-lim.github.io](https://terence-lim.github.io) 103 | -------------------------------------------------------------------------------- /src/actuarialmath.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: actuarialmath 3 | Version: 0.0.15 4 | Summary: A package for actuarial math and life contingent risks 5 | Author: Terence Lim 6 | Project-URL: Homepage, https://terence-lim.github.io/actuarialmath-guide 7 | Project-URL: Bug Tracker, https://github.com/terence-lim/actuarialmath/issues 8 | Classifier: Programming Language :: Python :: 3 9 | Classifier: License :: OSI Approved :: MIT License 10 | Classifier: Operating System :: OS Independent 11 | Requires-Python: >=3.10 12 | Description-Content-Type: text/x-rst 13 | License-File: LICENSE 14 | 15 | actuarialmath - Life Contingent Risks with Python 16 | ================================================= 17 | 18 | This Python package implements fundamental methods for modeling life contingent risks, and closely follows the coverage of traditional topics in actuarial exams and standard texts such as the "Fundamentals of Actuarial Math - Long-term" exam syllabus by the Society of Actuaries, and "Actuarial Mathematics for Life Contingent Risks" by Dickson, Hardy and Waters. 19 | 20 | Overview 21 | -------- 22 | 23 | The package comprises three sets of classes, which: 24 | 25 | 1. Implement general actuarial methods 26 | 27 | - Basic interest theory and probability laws 28 | 29 | - Survival functions, expected future lifetimes and fractional ages 30 | 31 | - Insurance, annuity, premiums, policy values, and reserves calculations 32 | 33 | 34 | 2. Adjust results for 35 | 36 | - Extra mortality risks 37 | 38 | - 1/mthly payment frequency using UDD or Woolhouse approaches 39 | 40 | 3. Specify and load a particular form of assumptions 41 | 42 | - Recursion inputs 43 | 44 | - Life table, select life table, or standard ultimate life table 45 | 46 | - Mortality laws, such as constant force of maturity, beta and uniform distributions, or Makeham's and Gompertz's laws 47 | 48 | 49 | Quick Start 50 | ----------- 51 | 52 | 1. ``pip install actuarialmath`` 53 | 54 | - also requires `numpy`, `scipy`, `matplotlib` and `pandas`. 55 | 56 | 2. Start Python (version >= 3.10) or Jupyter-notebook 57 | 58 | - Select a suitable subclass to initialize with your actuarial assumptions, such as `MortalityLaws` (or a special law like `ConstantForce`), `LifeTable`, `SULT`, `SelectLife` or `Recursion`. 59 | 60 | - Call appropriate methods to compute intermediate or final results, or to `solve` parameter values implicitly. 61 | 62 | - Adjust the answers with `ExtraRisk` or `Mthly` (or its `UDD` or `Woolhouse`) classes. 63 | 64 | Examples 65 | -------- 66 | 67 | :: 68 | 69 | # SOA FAM-L sample question 5.7 70 | from actuarialmath import Recursion, Woolhouse 71 | # initialize Recursion class with actuarial inputs 72 | life = Recursion().set_interest(i=0.04)\ 73 | .set_A(0.188, x=35)\ 74 | .set_A(0.498, x=65)\ 75 | .set_p(0.883, x=35, t=30) 76 | # modfy the standard results with Woolhouse mthly approximation 77 | mthly = Woolhouse(m=2, life=life, three_term=False) 78 | # compute the desired temporary annuity value 79 | print(1000 * mthly.temporary_annuity(35, t=30)) # solution = 17376.7 80 | 81 | :: 82 | 83 | # SOA FAM-L sample question 7.20 84 | from actuarialmath import SULT, Contract 85 | life = SULT() 86 | # compute the required FPT policy value 87 | S = life.FPT_policy_value(35, t=1, b=1000) # is always 0 in year 1! 88 | # input the given policy contract terms 89 | contract = Contract(benefit=1000, 90 | initial_premium=.3, 91 | initial_policy=300, 92 | renewal_premium=.04, 93 | renewal_policy=30) 94 | # compute gross premium using the equivalence principle 95 | G = life.gross_premium(A=life.whole_life_insurance(35), **contract.premium_terms) 96 | # compute the required policy value 97 | R = life.gross_policy_value(35, t=1, contract=contract.set_contract(premium=G)) 98 | print(R-S) # solution = -277.19 99 | 100 | Resources 101 | --------- 102 | 103 | 1. `Colab `_ or `Jupyter notebook `_, to solve all sample SOA FAM-L exam questions 104 | 105 | 2. `Online User Guide `_, or `download pdf `_ 106 | 107 | 3. `API reference `_ 108 | 109 | 4. `Github repo `_ and `issues `_ 110 | 111 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | actuarialmath - Solve Life Contingent Risks with Python 2 | ======================================================= 3 | 4 | This Python package implements fundamental methods for modeling life contingent risks, and closely follows the coverage of traditional topics in actuarial exams and standard texts such as the "Fundamentals of Actuarial Math - Long-term" exam syllabus by the Society of Actuaries, and "Actuarial Mathematics for Life Contingent Risks" by Dickson, Hardy and Waters. 5 | 6 | Overview 7 | -------- 8 | 9 | The package comprises three sets of classes, which: 10 | 11 | 1. Implement general actuarial methods 12 | 13 | - Basic interest theory and probability laws 14 | 15 | - Survival functions, expected future lifetimes and fractional ages 16 | 17 | - Insurance, annuity, premiums, policy values, and reserves calculations 18 | 19 | 20 | 2. Adjust results for 21 | 22 | - Extra mortality risks 23 | 24 | - 1/mthly payment frequency using UDD or Woolhouse approaches 25 | 26 | 3. Specify and load a particular form of assumptions 27 | 28 | - Recursion inputs 29 | 30 | - Life table, select life table, or standard ultimate life table 31 | 32 | - Mortality laws, such as constant force of maturity, beta and uniform distributions, or Makeham's and Gompertz's laws 33 | 34 | 35 | Quick Start 36 | ----------- 37 | 38 | 1. ``pip install actuarialmath`` 39 | 40 | - also requires `numpy`, `scipy`, `matplotlib` and `pandas`. 41 | 42 | 2. Start Python (version >= 3.10) or Jupyter-notebook 43 | 44 | - Select a suitable subclass to initialize with your actuarial assumptions, such as `MortalityLaws` (or a special law like `ConstantForce`), `LifeTable`, `SULT`, `SelectLife` or `Recursion`. 45 | 46 | - Call appropriate methods to compute intermediate or final results, or to `solve` parameter values implicitly. 47 | 48 | - Adjust answers with `ExtraRisk` or `Mthly` (or its `UDD` or `Woolhouse`) classes. 49 | 50 | Examples 51 | -------- 52 | 53 | :: 54 | 55 | # SOA FAM-L sample question 5.7 56 | from actuarialmath import Recursion, Woolhouse 57 | # initialize Recursion class with actuarial inputs 58 | life = Recursion().set_interest(i=0.04)\ 59 | .set_A(0.188, x=35)\ 60 | .set_A(0.498, x=65)\ 61 | .set_p(0.883, x=35, t=30) 62 | # modfy the standard results with Woolhouse mthly approximation 63 | mthly = Woolhouse(m=2, life=life, three_term=False) 64 | # compute the desired temporary annuity value 65 | print(1000 * mthly.temporary_annuity(35, t=30)) # solution = 17376.7 66 | 67 | :: 68 | 69 | # SOA FAM-L sample question 7.20 70 | from actuarialmath import SULT, Contract 71 | life = SULT() 72 | # compute the required FPT policy value 73 | S = life.FPT_policy_value(35, t=1, b=1000) # is always 0 in year 1! 74 | # input the given policy contract terms 75 | contract = Contract(benefit=1000, 76 | initial_premium=.3, 77 | initial_policy=300, 78 | renewal_premium=.04, 79 | renewal_policy=30) 80 | # compute gross premium using the equivalence principle 81 | G = life.gross_premium(A=life.whole_life_insurance(35), **contract.premium_terms) 82 | # compute the required policy value 83 | R = life.gross_policy_value(35, t=1, contract=contract.set_contract(premium=G)) 84 | print(R-S) # solution = -277.19 85 | 86 | Resources 87 | --------- 88 | 89 | 1. `Jupyter notebook `_ or `run in Colab `_, to solve all sample SOA FAM-L exam questions 90 | 91 | 2. `User Guide `_, or `download pdf `_ 92 | 93 | 3. `API reference `_ 94 | 95 | 4. `Github repo `_ and `issues `_ 96 | 97 | .. toctree:: 98 | :maxdepth: 2 99 | :caption: APIs: 100 | 101 | actuarialmath.actuarial 102 | actuarialmath.interest 103 | actuarialmath.life 104 | actuarialmath.survival 105 | actuarialmath.lifetime 106 | actuarialmath.fractional 107 | actuarialmath.insurance 108 | actuarialmath.annuity 109 | actuarialmath.premiums 110 | actuarialmath.policyvalues 111 | actuarialmath.reserves 112 | actuarialmath.recursion 113 | actuarialmath.lifetable 114 | actuarialmath.sult 115 | actuarialmath.selectlife 116 | actuarialmath.mortalitylaws 117 | actuarialmath.constantforce 118 | actuarialmath.extrarisk 119 | actuarialmath.mthly 120 | actuarialmath.udd 121 | actuarialmath.woolhouse 122 | 123 | 124 | Indices and tables 125 | ------------------ 126 | 127 | * :ref:`genindex` 128 | * :ref:`modindex` 129 | * :ref:`search` 130 | -------------------------------------------------------------------------------- /src/actuarialmath/actuarial.py: -------------------------------------------------------------------------------- 1 | """Define base class for actuarial math, with utility helpers and constants 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import scipy 8 | import matplotlib.pyplot as plt 9 | from typing import Callable, Any, Tuple, List 10 | 11 | plt.style.use('seaborn-dark') # 'ggplot' 12 | 13 | class Actuarial(object): 14 | """Define constants and common utility functions 15 | 16 | Constants: 17 | VARIANCE : select variance as the statistical moment to calculate 18 | 19 | WHOLE : indicates that term of insurance or annuity is Whole Life 20 | """ 21 | # constants 22 | VARIANCE = -2 23 | WHOLE = -999 24 | _VARIANCE = VARIANCE 25 | _WHOLE = WHOLE 26 | _TOL = 1e-6 27 | _verbose = 0 28 | _MAXAGE = 100 # default oldest age 29 | _MINAGE = 0 # default youngest age 30 | 31 | # 32 | # Helpers for numerical computations 33 | # 34 | @staticmethod 35 | def isclose(r: float, target: float = 0., abs_tol=1e-6) -> bool: 36 | """Is close to zero or target value 37 | 38 | Args: 39 | r : value to test if close to zero or target 40 | target : target value, default is 0.0 41 | """ 42 | return math.isclose(r, target, abs_tol=abs_tol) 43 | 44 | @staticmethod 45 | def integral(fun: Callable[[float], float], 46 | lower: float, upper: float) -> float: 47 | """Compute integral of the function between lower and upper limits 48 | 49 | Args: 50 | fun : function to integrate 51 | upper : upper limit 52 | lower : lower limit 53 | """ 54 | y = scipy.integrate.quad(fun, lower, upper, full_output=1) 55 | return y[0] 56 | 57 | @staticmethod 58 | def derivative(fun: Callable[[float], float], x: float) -> float: 59 | """Compute derivative of the function at a value 60 | 61 | Args: 62 | fun : function to compute derivative 63 | x : value to compute derivative at 64 | 65 | Examples: 66 | >>> print(Actuarial.derivative(fun=lambda x: x/50, x=25)) 67 | """ 68 | return scipy.misc.derivative(fun, x0=x, dx=1) 69 | 70 | @staticmethod 71 | def solve(fun: Callable[[float], float], target: float, 72 | grid: float | Tuple | List, mad: bool = False) -> float: 73 | """Solve root, or parameter that minimizes absolute value, of a function 74 | 75 | Args: 76 | fun : function to compute output given input values 77 | target : target value of function output 78 | grid : initial range of guesses 79 | root : whether to solve root (True), or minimize absolute function (False) 80 | 81 | Returns: 82 | value s.t. output of function fun(value) ~ target 83 | 84 | Examples: 85 | >>> print(Actuarial.solve(fun=lambda omega: 1/omega, 86 | >>> target=0.05, grid=[1, 100])) 87 | """ 88 | if mad: # minimize absolute difference 89 | f = lambda t: abs(fun(t) - target) 90 | return scipy.optimize.minimize_scalar(f, grid).x 91 | else: # solve root 92 | f = lambda x: fun(x) - target 93 | if isinstance(grid, (list, tuple)): 94 | grid = min([(abs(f(x)), x) # guess can be list of guesses 95 | for x in np.linspace(min(grid), max(grid), 5)])[1] 96 | output = scipy.optimize.fsolve(f, [grid], full_output=True) 97 | fun(output[0][0]) # call again with final in case want side effect 98 | return output[0][0] 99 | 100 | def add_term(self, t: int, n: int) -> int: 101 | """Add two terms, either term may be Whole Life 102 | 103 | Args: 104 | t : first term to add 105 | n : second term to add 106 | """ 107 | if t == self.WHOLE or n == self.WHOLE: 108 | return self.WHOLE # adding any term to WHOLE is still WHOLE 109 | return t + n 110 | 111 | def max_term(self, x: int, t: int, u: int = 0) -> int: 112 | """Decrease term t if adding deferral period u to (x) exceeds maxage 113 | 114 | Args: 115 | x : age 116 | t : term of insurance or annuity, after deferral period 117 | u : term deferred 118 | 119 | Returns: 120 | value of term t adjusted by deferral and maxage s.t. maxage not exceeded 121 | """ 122 | if t < 0 or x + t + u > self._MAXAGE: 123 | return self._MAXAGE - (x + u) 124 | return t 125 | 126 | if __name__ == "__main__": 127 | actuarial = Actuarial() 128 | def as_term(t): return "WHOLE_LIFE" if t == Actuarial.WHOLE else t 129 | 130 | for a,b in [(3, Actuarial.WHOLE), (3, 2), (3, -1)]: 131 | print(f"({as_term(a)}) + ({as_term(b)}) =", 132 | as_term(actuarial.add_term(a, b))) 133 | 134 | print(Actuarial.solve(fun=lambda omega: 1/omega, 135 | target=0.05, 136 | grid=[1, 100])) 137 | print(Actuarial.derivative(fun=lambda x: x/50, x=25)) 138 | -------------------------------------------------------------------------------- /src/actuarialmath/interest.py: -------------------------------------------------------------------------------- 1 | """Interest Theory - Applies interest rate formulas 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import pandas as pd 8 | from typing import Callable 9 | from actuarialmath import Actuarial 10 | 11 | class Interest(Actuarial): 12 | """Store an assumed interest rate, and compute interest rate functions 13 | 14 | Args: 15 | i : assumed annual interest rate 16 | d : or annual discount rate 17 | v : or annual discount factor 18 | delta : or continuously compounded interest rate 19 | v_t : or discount rate as a function of time 20 | i_m : or m-thly interest rate 21 | d_m : or m-thly discount rate 22 | m : m'thly frequency, if i_m or d_m are specified 23 | """ 24 | 25 | def __init__(self, i: float = -1., delta: float = -1., d: float = -1., 26 | v: float = -1., i_m: float = -1., d_m: float = -1., m: int = 0, 27 | v_t: Callable[[float], float] | None = None): 28 | if i_m >= 0: # given interest rate mthly compounded 29 | i = self.mthly(m=m, i_m=i_m) 30 | if d_m >= 0: # given discount rate mthly compounded 31 | d = self.mthly(m=m, d_m=d_m) 32 | if v_t is None: 33 | if delta >= 0: # given continously-compounded rate 34 | self._i = math.exp(delta) - 1 35 | elif d >= 0: # given annual discount rate 36 | self._i = d / (1 - d) 37 | elif v >= 0 : # given annual discount factor 38 | self._i = (1 / v) - 1 39 | elif i >= 0: 40 | self._i = i 41 | else: # given annual interest rate 42 | raise Exception("non-negative interest rate not given") 43 | self._v_t = lambda t: self._v**t 44 | self._v = 1 / (1 + self._i) # store discount factor 45 | self._d = self._i / (1 + self._i) # store discount rate 46 | self._delta = math.log(1 + self._i) # store continuous rate 47 | else: # given discount function 48 | assert callable(v_t), "v_t must be a callable discount function" 49 | assert v_t(0) == 1, "v_t(t=0) must equal 1" 50 | self._v_t = v_t 51 | #self._i = (1 / v_t(1)) - 1 52 | self._v = self._d = self._i = self._delta = None 53 | 54 | @property 55 | def i(self) -> float: 56 | """effective annual interest rate""" 57 | return self._i 58 | 59 | @property 60 | def d(self) -> float: 61 | """annual discount rate of interest""" 62 | return self._d 63 | 64 | @property 65 | def delta(self) -> float: 66 | """continuously compounded interest rate, or force of interest, per year""" 67 | return self._delta 68 | 69 | @property 70 | def v(self) -> float: 71 | """annual discount factor""" 72 | return self._v 73 | 74 | @property 75 | def v_t(self) -> Callable: 76 | """discount factor as a function of time""" 77 | return self._v_t 78 | 79 | 80 | def annuity(self, t: int = -1, m: int = 1, due: bool = True) -> float: 81 | """Compute value of the annuity certain factor 82 | 83 | Args: 84 | t : number of years of payments 85 | m : m'thly frequency of payments (0 for continuous payments) 86 | due : whether annuity due (True) or immediate (False) 87 | 88 | Examples: 89 | >>> print(interest.annuity(t=10, due=False), 2.831059) 90 | """ 91 | v_t = 0 if t < 0 else self.v**t # is t finite 92 | assert m >= 0, "mthly frequency must be non-negative" 93 | if m == 0: # if continuous 94 | return (1 - v_t) / self.delta 95 | elif due: # if annuity due 96 | return (1 - v_t) / self.mthly(m=m, d=self.d) 97 | else: # if annuity immediate 98 | return (1 - v_t) / self.mthly(m=m, i=self.i) 99 | 100 | @staticmethod 101 | def mthly(m: int = 0, i: float = -1, d: float = -1, 102 | i_m: float = -1, d_m: float = -1) -> float: 103 | """Convert to or from m'thly interest rates 104 | 105 | Args: 106 | m : m'thly frequency 107 | i : an annual-pay interest rate, to convert to m'thly 108 | d : or annual-pay discount rate, to convert to m'thly 109 | i_m : or m'thly interest rate, to convert to annual pay 110 | d_m : or m'thly discount rate, to convert to annual pay 111 | 112 | Examples: 113 | >>> i = Interest.mthly(i_m=0.05, m=12) 114 | >>> print("Convert mthly to annual-pay:", i) 115 | >>> print("Convert annual-pay to mthly:", Interest.mthly(i=i, m=12)) 116 | """ 117 | assert m >= 0, "mthly frequency must be non-negative" 118 | if i > 0: 119 | return m * ((1 + i)**(1 / m) - 1) if m else math.log(1+i) 120 | elif d > 0: 121 | return m * (1 - (1 - d)**(1 / m)) if m else -math.log(1-d) 122 | elif i_m > 0: 123 | return (1 + (i_m / m))**m - 1 124 | elif d_m > 0: 125 | return 1 - (1 - (d_m / m))**m 126 | else: 127 | raise Exception("no interest rate given to mthly") 128 | 129 | @staticmethod 130 | def double_force(i: float = -1., delta: float = -1., d: float = -1., 131 | v: float = -1.): 132 | """Double the force of interest 133 | 134 | Args: 135 | i : interest rate to double force of interest 136 | d : or discount rate 137 | v : or discount factor 138 | delta : or continuous rate 139 | 140 | Returns: 141 | interest rate, of same form as input rate, after doubling force of interest 142 | 143 | Examples: 144 | >>> print("Double force of interest of i =", Interest.double_force(i=0.05)) 145 | >>> print("Double force of interest of d =", Interest.double_force(d=0.05)) 146 | """ 147 | if delta >= 0: 148 | return 2 * delta 149 | elif v >= 0: 150 | return v**2 151 | elif d >= 0: 152 | return 2 * d - (d**2) 153 | elif i >= 0: 154 | return 2 * i + (i**2) 155 | else: 156 | raise Exception("no interest rate for double_force") 157 | 158 | if __name__ == "__main__": 159 | print("SOA Question 3.10: (C) 0.86") 160 | interest = Interest(v=0.75) 161 | L = 35 * interest.annuity(t=4, due=False) + 75 * interest.v_t(t=5) 162 | interest = Interest(v=0.5) 163 | R = 15 * interest.annuity(t=4, due=False) + 25 * interest.v_t(t=5) 164 | ans = L / (L + R) 165 | print(ans) 166 | 167 | print("Double the force of interest") 168 | i = 0.05 169 | i2 = Interest.double_force(i=i) 170 | print(i2) 171 | 172 | print("Convert interest to discount rate") 173 | d2 = Interest(i=i2).d 174 | print(d2) 175 | 176 | print("Convert mthly to annual-pay:") 177 | i = Interest.mthly(i_m=0.05, m=12) 178 | print(i) 179 | 180 | print("Convert annual-pay to mthly:") 181 | i_m = Interest.mthly(i=i, m=12) 182 | print(i_m) 183 | 184 | 185 | -------------------------------------------------------------------------------- /src/actuarialmath/life.py: -------------------------------------------------------------------------------- 1 | """Life Contingent Risks - Applies probability laws 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import pandas as pd 8 | from scipy.special import ndtri 9 | from scipy.stats import norm 10 | from typing import Callable, Dict, Any, Tuple, List 11 | from actuarialmath import Actuarial, Interest 12 | 13 | class Life(Actuarial): 14 | """Compute moments and probabilities""" 15 | 16 | def __init__(self, **kwargs): 17 | super().__init__(**kwargs) 18 | self.set_interest(i=0) 19 | 20 | def set_interest(self, **interest) -> "Life": 21 | """Set interest rate, which can be given in any form 22 | 23 | Args: 24 | i : assumed annual interest rate 25 | d : or assumed discount rate 26 | v : or assumed discount factor 27 | delta : or assumed contiuously compounded interest rate 28 | v_t : or assumed discount rate as a function of time 29 | i_m : or assumed monthly interest rate 30 | d_m : or assumed monthly discount rate 31 | m : m'thly frequency, if i_m or d_m are given 32 | """ 33 | self.interest = Interest(**interest) 34 | return self 35 | 36 | # 37 | # Probability theory 38 | # 39 | @staticmethod 40 | def variance(a, b, var_a, var_b, cov_ab: float) -> float: 41 | """Variance of weighted sum of two r.v. 42 | 43 | Args: 44 | a : weight on first r.v. 45 | b : weight on other r.v. 46 | var_a : variance of first r.v. 47 | var_b : variance of other r.v. 48 | cov_ab : covariance of the r.v.'s 49 | """ 50 | return a**2 * var_a + b**2 * var_b + 2 * a * b * cov_ab 51 | 52 | @staticmethod 53 | def covariance(a, b, ab: float) -> float: 54 | """Covariance of two r.v. 55 | 56 | Args: 57 | a : expected value of first r.v. 58 | b : expected value of other r.v. 59 | ab : expected value of product of the two r.v. 60 | """ 61 | return ab - a * b # Cov(X,Y) = E[XY] - E[X] E[Y] 62 | 63 | @staticmethod 64 | def bernoulli(p, a: float = 1, b: float = 0, 65 | variance: bool = False) -> float: 66 | """Mean or variance of bernoulli r.v. with values {a, b} 67 | 68 | Args: 69 | p : probability of first value 70 | a : first value 71 | b : other value 72 | variance : whether to return variance (True) or mean (False) 73 | """ 74 | assert 0 <= p <= 1. 75 | return (a - b)**2 * p * (1-p) if variance else p * a + (1-p) * b 76 | 77 | @staticmethod 78 | def binomial(p: float, N: int, variance: bool = False) -> float: 79 | """Mean or variance of binomial r.v. 80 | 81 | Args: 82 | p : probability of occurence 83 | N : number of trials 84 | variance : whether to return variance (True) or mean (False) 85 | """ 86 | assert 0 <= p <= 1. and N >= 1 87 | return N * p * (1-p) if variance else N * p 88 | 89 | @staticmethod 90 | def mixture(p, p1, p2: float, N: int = 1, variance: bool = False) -> float: 91 | """Mean or variance of binomial mixture 92 | 93 | Args: 94 | p : probability of selecting first r.v. 95 | p1 : probability of occurrence if first r.v. 96 | p2 : probability of occurrence if other r.v. 97 | N : number of trials 98 | variance : whether to return variance (True) or mean (False) 99 | 100 | Examples: 101 | >>> p1 = (1. - 0.02) * (1. - 0.01) # 2_p_x if vaccine given 102 | >>> p2 = (1. - 0.02) * (1. - 0.02) # 2_p_x if vaccine not given 103 | >>> math.sqrt(Life.mixture(p=.2, p1=p1, p2=p2, N=100000, variance=True)) 104 | """ 105 | assert 0 <= p <= 1 and 0 <= p1 <= 1 and 0 <= p2 <= 1 and N >= 1 106 | mean1 = Life.binomial(p1, N) 107 | mean2 = Life.binomial(p2, N) 108 | if variance: 109 | var1 = Life.binomial(p1, N, variance=True) 110 | var2 = Life.binomial(p2, N, variance=True) 111 | return (Life.bernoulli(p, mean1**2 + var1, mean2**2 + var2) - 112 | Life.bernoulli(p, mean1, mean2)**2) 113 | else: 114 | return Life.bernoulli(p, mean1, mean2) 115 | 116 | @staticmethod 117 | def conditional_variance(p, p1, p2: float, N: int = 1) -> float: 118 | """Conditional variance formula for mixture of binomials 119 | 120 | Args: 121 | p : probability of selecting first r.v. 122 | p1 : probability of occurence for first r.v. 123 | p2 : probability of occurence for other r.v. 124 | N : number of trials 125 | 126 | Examples: 127 | >>> p1 = (1. - 0.02) * (1. - 0.01) # 2_p_x if vaccine given 128 | >>> p2 = (1. - 0.02) * (1. - 0.02) # 2_p_x if vaccine not given 129 | >>> math.sqrt(Life.mixture(p=.2, p1=p1, p2=p2, N=100000, variance=True)) 130 | """ 131 | assert 0 <= p <= 1 and 0 <= p1 <= 1 and 0 <= p2 <= 1 and N >= 1 132 | mean1 = Life.binomial(p1, N) 133 | mean2 = Life.binomial(p2, N) 134 | var1 = Life.binomial(p1, N, variance=True) 135 | var2 = Life.binomial(p2, N, variance=True) 136 | return (Life.bernoulli(p, mean1, mean2, variance=True) # var of mean 137 | + Life.bernoulli(p, var1, var2)) # plus mean of var 138 | 139 | @staticmethod 140 | def portfolio_percentile(mean: float, variance: float, 141 | prob: float, N: int = 1) -> float: 142 | """Probability percentile of the sum of N iid r.v.'s 143 | 144 | Args: 145 | mean : mean of each independent obsevation 146 | variance : variance of each independent observation 147 | prob : probability threshold 148 | N : number of observations to sum 149 | """ 150 | assert prob < 1.0 151 | mean *= N 152 | variance *= N 153 | return mean + ndtri(prob) * math.sqrt(variance) 154 | 155 | @staticmethod 156 | def portfolio_cdf(mean: float, variance: float, value: float, 157 | N: int = 1) -> float: 158 | """Probability distribution of a value in the sum of N iid r.v. 159 | 160 | Args: 161 | mean : mean of each independent obsevation 162 | variance : variance of each independent observation 163 | value : value to compute probability distribution in the sum 164 | N : number of observations to sum 165 | """ 166 | mean *= N 167 | variance *= N 168 | return norm.cdf(value, loc=mean, scale=math.sqrt(variance)) 169 | 170 | @staticmethod 171 | def quantiles_frame(quantiles: List[float] = [.8, .85, .9, .95, 172 | .975, .99, .995]) -> Any: 173 | """Display selected quantile values from Normal distribution table 174 | 175 | Args: 176 | quantiles : list of quantiles to display normal distribution values 177 | """ 178 | columns = [round(Life.portfolio_percentile(0, 1, p), 3) for p in quantiles] 179 | tab = pd.DataFrame.from_dict(data={'Pr(Z<=z)': quantiles}, 180 | columns=columns, orient='index')\ 181 | .rename_axis('z', axis="columns") 182 | return tab.round(3) 183 | 184 | 185 | if __name__ == "__main__": 186 | print("SOA Question 2.2: (D) 400") 187 | p1 = (1. - 0.02) * (1. - 0.01) # 2_p_x if vaccine given 188 | p2 = (1. - 0.02) * (1. - 0.02) # 2_p_x if vaccine not given 189 | cond = math.sqrt(Life.conditional_variance(p=.2, p1=p1, p2=p2, N=100000)) 190 | print(cond) # conditional variance formula 191 | mix = math.sqrt(Life.mixture(p=.2, p1=p1, p2=p2, N=100000, variance=True)) 192 | print(mix) # mixture of distributions formula 193 | 194 | print() 195 | print("Values of z for selected values of Pr(Z<=z)") 196 | print("-------------------------------------------") 197 | print(Life.quantiles_frame().to_string(float_format=lambda x: f"{x:.3f}")) 198 | print() 199 | 200 | -------------------------------------------------------------------------------- /src/actuarialmath/sult.py: -------------------------------------------------------------------------------- 1 | """SULT - Loads and uses a standard ultimate life table 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import pandas as pd 8 | from typing import Dict, Callable 9 | from actuarialmath import LifeTable 10 | 11 | # Makeham's Law parameters from SOA’s Excel Workbook for FAM-L Tables 12 | _A, _B, _c = 0.00022, 0.0000027, 1.124 13 | def _faml_sult(x, t: float) -> float: 14 | return math.exp(-_A*t - (_B*_c**x*(_c**t - 1)) / math.log(_c)) 15 | 16 | class SULT(LifeTable): 17 | """Generates and uses a standard ultimate life table 18 | 19 | Args: 20 | i : interest rate 21 | radix : initial number of lives 22 | minage : minimum age 23 | maxage : maximum age 24 | S : survival function, default is Makeham with SOA FAM-L parameters 25 | 26 | Examples: 27 | >>> sult = SULT() 28 | >>> a = sult.temporary_annuity(70, t=10) 29 | >>> A = sult.deferred_annuity(70, u=10) 30 | >>> P = sult.gross_premium(a=a, A=A, benefit=100000, initial_premium=0.75, 31 | >>> renewal_premium=0.05) 32 | """ 33 | 34 | 35 | def __init__(self, i: float = 0.05, radix: int = 100000, 36 | S: Callable[[float, float], float] = _faml_sult, 37 | minage: int = 20, maxage: int = 130, **kwargs): 38 | """Construct SULT""" 39 | super().__init__(**kwargs) 40 | l = {t+minage: radix * S(minage, t) for t in range(1+maxage-minage)} 41 | self.set_interest(i=i).set_table(l=l, minage=minage, maxage=maxage) 42 | 43 | def __getitem__(self, col: str) -> Dict[int, float]: 44 | """Returns a column of the sult table 45 | 46 | Args: 47 | col : name of life table column to return 48 | """ 49 | funs = {'q': (self.q_x, dict()), 50 | 'p': (self.p_x, dict()), 51 | 'a': (self.whole_life_annuity, dict()), 52 | 'A': (self.whole_life_insurance, dict())} 53 | assert col[0].lower() in funs, f"must be one of {list(funs.keys())}" 54 | f, args = funs[col[0]] 55 | return {x: f(x, **args) for x in range(self._MINAGE, self._MAXAGE)} 56 | 57 | def frame(self, minage: int = 20, maxage: int = 100): 58 | """Derive FAM-L exam table columns of SULT as a DataFrame 59 | 60 | Args: 61 | minage : first age to display row 62 | maxage : large age to display row 63 | """ 64 | # specify methods and arguments for computing columns of FAM-L exam table 65 | funs = {'q_x': (self.q_x, dict()), 66 | 'a_x': (self.whole_life_annuity, dict()), 67 | 'A_x': (self.whole_life_insurance, dict()), 68 | '2A_x': (self.whole_life_insurance, dict(moment=2)), 69 | 'a_x:10': (self.temporary_annuity, dict(t=10)), 70 | 'A_x:10': (self.endowment_insurance, dict(t=10)), 71 | 'a_x:20': (self.temporary_annuity, dict(t=20)), 72 | 'A_x:20': (self.endowment_insurance, dict(t=20)), 73 | '5_E_x': (self.E_x, dict(t=5)), 74 | '10_E_x': (self.E_x, dict(t=10)), 75 | '20_E_x': (self.E_x, dict(t=20))} 76 | t = {col: {x: f(x, **args) for x in range(self._MINAGE, self._MAXAGE)} 77 | for col, (f, args) in funs.items()} 78 | tab = pd.DataFrame(dict(l_x=self._table['l'])).sort_index() 79 | tab = tab.join(pd.DataFrame.from_dict(t).set_index(tab.index[:-1])) 80 | for digits, col in zip([1, 6, 4, 5, 5, 4, 5, 4, 5, 5, 5, 5], tab.columns): 81 | tab[col] = tab[col].map(f"{{:.{digits}f}}".format) 82 | return tab.loc[minage:maxage] 83 | 84 | if __name__ == "__main__": 85 | print("SOA Question 6.52: (D) 50.80") 86 | sult = SULT() 87 | a = sult.temporary_annuity(45, t=10) 88 | other_cost = 10 * sult.deferred_annuity(45, u=10) 89 | P = sult.gross_premium(a=a, A=0, benefit=0, 90 | initial_premium=1.05, renewal_premium=0.05, 91 | initial_policy=100 + other_cost, renewal_policy=20) 92 | print(a, P) 93 | print() 94 | 95 | 96 | print("SOA Question 6.47: (D) 66400") 97 | sult = SULT() 98 | a = sult.temporary_annuity(70, t=10) 99 | A = sult.deferred_annuity(70, u=10) 100 | P = sult.gross_premium(a=a, A=A, benefit=100000, initial_premium=0.75, 101 | renewal_premium=0.05) 102 | print(P) 103 | print() 104 | 105 | 106 | print("SOA Question 6.43: (C) 170") 107 | sult = SULT() 108 | a = sult.temporary_annuity(30, t=5) 109 | A = sult.term_insurance(30, t=10) 110 | other_expenses = 4 * sult.deferred_annuity(30, u=5, t=5) 111 | P = sult.gross_premium(a=a, A=A, benefit=200000, initial_premium=0.35, 112 | initial_policy=8 + other_expenses, renewal_policy=4, 113 | renewal_premium=0.15) 114 | print(P) 115 | print() 116 | 117 | print("SOA Question 6.39: (A) 29") 118 | sult = SULT() 119 | P40 = sult.premium_equivalence(sult.whole_life_insurance(40), b=1000) 120 | P80 = sult.premium_equivalence(sult.whole_life_insurance(80), b=1000) 121 | p40 = sult.p_x(40, t=10) 122 | p80 = sult.p_x(80, t=10) 123 | P = (P40 * p40 + P80 * p80) / (p80 + p40) 124 | print(P) 125 | print() 126 | 127 | 128 | print("SOA Question 6.37: (D) 820") 129 | sult = SULT() 130 | benefits = sult.whole_life_insurance(35, b=50000 + 100) 131 | expenses = sult.immediate_annuity(35, b=100) 132 | a = sult.temporary_annuity(35, t=10) 133 | print(benefits, expenses, a) 134 | print((benefits + expenses) / a) 135 | print() 136 | 137 | 138 | print("SOA Question 6.35: (D) 530") 139 | sult = SULT() 140 | A = sult.whole_life_insurance(35, b=100000) 141 | a = sult.whole_life_annuity(35) 142 | print(sult.gross_premium(a=a, A=A, initial_premium=.19, renewal_premium=.04)) 143 | print() 144 | 145 | 146 | print("SOA Question 5.8: (C) 0.92118") 147 | sult = SULT() 148 | a = sult.certain_life_annuity(55, u=5) 149 | print(sult.p_x(55, t=math.floor(a))) 150 | print() 151 | 152 | 153 | print("SOA Question 5.3: (C) 6.239") 154 | sult = SULT() 155 | t = 10.5 156 | print(t * sult.E_r(40, t=t)) 157 | print() 158 | 159 | 160 | print("SOA Question 4.17: (A) 1126.7") 161 | sult = SULT() 162 | median = sult.Z_t(48, prob=0.5, discrete=False) 163 | benefit = lambda x,t: 5000 if t < median else 10000 164 | print(sult.A_x(48, benefit=benefit)) 165 | print() 166 | 167 | 168 | print("SOA Question 4.14: (E) 390000 ") 169 | sult = SULT() 170 | p = sult.p_x(60, t=85-60) 171 | mean = sult.bernoulli(p) 172 | var = sult.bernoulli(p, variance=True) 173 | F = sult.portfolio_percentile(mean=mean, variance=var, prob=.86, N=400) 174 | print(F * 5000 * sult.interest.v_t(85-60)) 175 | print() 176 | 177 | from actuarialmath.interest import Interest 178 | print("SOA Question 4.5: (C) 35200") 179 | sult = SULT(udd=True).set_interest(delta=0.05) 180 | Z = 100000 * sult.Z_from_prob(45, prob=0.95, discrete=False) 181 | print(Z) 182 | 183 | print("SOA Question 3.9: (E) 3850") 184 | sult = SULT() 185 | p1 = sult.p_x(20, t=25) 186 | p2 = sult.p_x(45, t=25) 187 | mean = sult.bernoulli(p1) * 2000 + sult.bernoulli(p2) * 2000 188 | var = (sult.bernoulli(p1, variance=True) * 2000 189 | + sult.bernoulli(p2, variance=True) * 2000) 190 | print(sult.portfolio_percentile(mean=mean, variance=var, prob=.99)) 191 | print() 192 | 193 | 194 | print("SOA Question 3.8: (B) 1505") 195 | sult = SULT() 196 | p1 = sult.p_x(35, t=40) 197 | p2 = sult.p_x(45, t=40) 198 | mean = sult.bernoulli(p1) * 1000 + sult.bernoulli(p2) * 1000 199 | var = (sult.bernoulli(p1, variance=True) * 1000 200 | + sult.bernoulli(p2, variance=True) * 1000) 201 | print(sult.portfolio_percentile(mean=mean, variance=var, prob=.95)) 202 | print() 203 | 204 | 205 | print("SOA Question 3.4: (B) 815") 206 | sult = SULT() 207 | mean = sult.p_x(25, t=95-25) 208 | var = sult.bernoulli(mean, variance=True) 209 | print(sult.portfolio_percentile(N=4000, mean=mean, variance=var, prob=.1)) 210 | print() 211 | 212 | print("Standard Ultimate Life Table at i=0.05") 213 | print(sult.frame()) 214 | print() 215 | -------------------------------------------------------------------------------- /src/actuarialmath/woolhouse.py: -------------------------------------------------------------------------------- 1 | """Woolhouse - 1/mthly insurance and annuities with Woolhouse's approximation 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | from typing import Callable 7 | from actuarialmath import Mthly 8 | from actuarialmath import Annuity 9 | 10 | class Woolhouse(Mthly): 11 | """1/m'thly shortcuts with Woolhouse approximation 12 | 13 | Args: 14 | m : number of payments per year 15 | life : original fractional survival and mortality functions 16 | three_term : whether to include (True) or ignore (False) third term 17 | approximate_mu : exact (False), approximate (True) or function for third term 18 | """ 19 | 20 | def __init__(self, m: int, life: Annuity, three_term: bool = False, 21 | approximate_mu: Callable[[int, int], float] | bool = True): 22 | super().__init__(m=m, life=life) 23 | self.three_term = three_term # whether to include third term 24 | self.approximate_mu = approximate_mu # how to approximate mu 25 | 26 | def mu_x(self, x: int, s: int = 0) -> float: 27 | """Computes mu_x or calls approximate_mu for third term 28 | 29 | Args: 30 | x : age of selection 31 | s : years after selection 32 | """ 33 | if self.approximate_mu is True: # approximate mu from integer ages 34 | return -.5 * sum([math.log(self.life.p_x(x, s=s+t)) for t in [0,-1]]) 35 | elif self.approximate_mu is False: 36 | return self.life.mu_x(x, s=s) # use exact mu from survival model 37 | else: # apply custom function for mu 38 | return self.approximate_mu(x, s) 39 | 40 | def whole_life_insurance(self, x: int, s: int = 0, b: int = 1, 41 | mu: float | None = 0.) -> float: 42 | """1/m'thly Woolhouse whole life insurance: A_x 43 | 44 | Args: 45 | x : age of selection 46 | s : years after selection 47 | b : amount of benefit 48 | mu : value of mu at age x+s 49 | """ 50 | return b * self.insurance_twin(self.whole_life_annuity(x, s=s, mu=mu)) 51 | 52 | def term_insurance(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 53 | b: int = 1, mu: float | None = 0., 54 | mu1: float | None = None) -> float: 55 | """1/m'thly Woolhouse term insurance: A_x:t 56 | 57 | Args: 58 | x : year of selection 59 | s : years after selection 60 | t : term of insurance in years 61 | b : amount of benefit 62 | mu : value of mu at age x+s 63 | mu1 : value of mu at age x+s+t 64 | """ 65 | A = self.whole_life_insurance(x, s=s, b=b, mu=mu) 66 | if t < 0 or self.life.max_term(x+s, t) < t: 67 | return A 68 | E = self.E_x(x, s=s, t=t) 69 | return A - E * self.whole_life_insurance(x, s=s+t, b=b, mu=mu1) 70 | 71 | def endowment_insurance(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 72 | b: int = 1, endowment: int = -1, 73 | mu: float | None = None) -> float: 74 | """1/m'thly Woolhouse term insurance: A_x:t 75 | 76 | Args: 77 | x : year of selection 78 | s : years after selection 79 | t : term of insurance in years 80 | b : amount of benefit 81 | endowment : amount of endowment 82 | mu : value of mu at age x+s+t 83 | """ 84 | if endowment < 0: 85 | endowment = b 86 | E = self.E_x(x, s=s, t=t) 87 | A = self.term_insurance(x, s=s, t=t, b=b, mu=mu) 88 | return A + E * (b if endowment < 0 else endowment) 89 | 90 | def deferred_insurance(self, x: int, s: int = 0, u: int = 0, 91 | t: int = Annuity.WHOLE, b: int = 1, 92 | mu: float | None = None, 93 | mu1: float | None = None) -> float: 94 | """1/m'thly Woolhouse deferred insurance as discounted term or WL 95 | 96 | Args: 97 | x : year of selection 98 | s : years after selection 99 | u : number of years deferred 100 | t : term of insurance in years 101 | b : amount of benefit 102 | mu : value of mu at age x+s+u 103 | mu1 : value of mu at age x+s+u+t 104 | """ 105 | if self.life.max_term(x+s, u) < u: 106 | return 0. 107 | E = self.E_x(x, s=s, t=u) 108 | return E * self.term_insurance(x, s=s+u, t=t, b=b, mu=mu, mu1=mu1) 109 | 110 | def whole_life_annuity(self, x: int, s: int = 0, b: int = 1, 111 | mu: float | None = None) -> float: 112 | """1/m'thly Woolhouse whole life annuity: a_x 113 | 114 | Args: 115 | x : year of selection 116 | s : years after selection 117 | b : amount of benefit 118 | mu : value of mu at age x+s 119 | 120 | Examples: 121 | >>> life = Recursion().set_interest(i=0.05).set_a(3.4611, x=0) 122 | >>> Woolhouse(m=4, life=life).whole_life_annuity(x=0) 123 | """ 124 | a = (self.life.whole_life_annuity(x, s=s, discrete=True) - 125 | (self.m - 1)/(2 * self.m)) 126 | if self.three_term: 127 | mu = mu or self.mu_x(x, s) 128 | a -= (mu + self.life.interest.delta)*(self.m**2 - 1)/(12 * self.m**2) 129 | return a * b 130 | 131 | def temporary_annuity(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 132 | b: int = 1, mu: float | None = None, 133 | mu1: float | None = None) -> float: 134 | """1/m'thly Woolhouse temporary life annuity: a_x 135 | 136 | Args: 137 | x : year of selection 138 | s : years after selection 139 | t : term of annuity in years 140 | b : amount of benefit 141 | mu : value of mu at age x+s 142 | mu1 : value of mu at age x+s+t 143 | """ 144 | return self.deferred_annuity(x, s=s, t=t, b=b, mu=mu, mu1=mu1) # u=0 145 | 146 | def deferred_annuity(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 147 | u: int = 0, b: int = 1, mu: float | None = None, 148 | mu1: float | None = None) -> float: 149 | """1/m'thly Woolhouse deferred life annuity: a_x 150 | 151 | Args: 152 | x : year of selection 153 | s : years after selection 154 | u : years of deferral 155 | t : term of annuity in years 156 | mu : value of mu at age x+s+u 157 | mu1 : value of mu at age x+s+u+t 158 | """ 159 | a_x = self.whole_life_annuity(x, s=s+u, mu=mu) 160 | a_xt = self.whole_life_annuity(x, s=s+t+u, mu=mu1) if t > 0 else 0 161 | a = self.E_x(x, s=s, t=u) * (a_x - self.E_x(x, s=s+u, t=t) * a_xt) 162 | return a * b 163 | 164 | if __name__ == "__main__": 165 | from actuarialmath.sult import SULT 166 | from actuarialmath.recursion import Recursion 167 | from actuarialmath.udd import UDD 168 | from actuarialmath.policyvalues import Contract 169 | 170 | print("SOA Question 7.7: (D) 1110") 171 | x = 0 172 | life = Recursion().set_interest(i=0.05).set_A(0.4, x=x+10) 173 | a = Woolhouse(m=12, life=life).whole_life_annuity(x+10) 174 | print(a) 175 | contract = Contract(premium=0, benefit=10000, renewal_policy=100) 176 | V = life.gross_future_loss(A=0.4, contract=contract.renewals()) 177 | print(V) 178 | contract = Contract(premium=30*12, renewal_premium=0.05) 179 | V1 = life.gross_future_loss(a=a, contract=contract.renewals()) 180 | print(V, V1, V+V1) 181 | print() 182 | 183 | 184 | print("SOA Question 6.25: (C) 12330") 185 | life = SULT() 186 | woolhouse = Woolhouse(m=12, life=life) 187 | benefits = woolhouse.deferred_annuity(55, u=10, b=1000 * 12) 188 | expenses = life.whole_life_annuity(55, b=300) 189 | payments = life.temporary_annuity(55, t=10) 190 | print(benefits + expenses, payments) 191 | def fun(P): 192 | return life.gross_future_loss(A=benefits + expenses, a=payments, 193 | contract=Contract(premium=P)) 194 | P = life.solve(fun, target=-800, grid=[12110, 12550]) 195 | print(P) 196 | print() 197 | 198 | 199 | print("SOA Question 6.15: (B) 1.002") 200 | life = Recursion().set_interest(i=0.05).set_a(3.4611, x=0) 201 | A = life.insurance_twin(3.4611) 202 | udd = UDD(m=4, life=life) 203 | a1 = udd.whole_life_annuity(x=x) 204 | woolhouse = Woolhouse(m=4, life=life) 205 | a2 = woolhouse.whole_life_annuity(x=x) 206 | print(life.gross_premium(a=a1, A=A)/life.gross_premium(a=a2, A=A)) 207 | print() 208 | 209 | print("SOA Question 5.7: (C) 17376.7") 210 | life = Recursion().set_interest(i=0.04) 211 | life.set_A(0.188, x=35) 212 | life.set_A(0.498, x=65) 213 | life.set_p(0.883, x=35, t=30) 214 | mthly = Woolhouse(m=2, life=life, three_term=False) 215 | print(mthly.temporary_annuity(35, t=30)) 216 | print(1000 * mthly.temporary_annuity(35, t=30)) 217 | print() 218 | -------------------------------------------------------------------------------- /src/actuarialmath/survival.py: -------------------------------------------------------------------------------- 1 | """Survival models - Computes survival and mortality functions 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | from typing import Callable, Tuple, Any, List 6 | import math 7 | import numpy as np 8 | from actuarialmath import Life 9 | 10 | class Survival(Life): 11 | """Set and derive basic survival and mortality functions""" 12 | _RADIX = 100000 # default initial number of lives in life table 13 | 14 | def set_survival(self, 15 | S: Callable[[int,float,float], float] | None = None, 16 | f: Callable[[int,float,float], float] | None = None, 17 | l: Callable[[int,float], float] | None = None, 18 | mu: Callable[[int,float], float] | None = None, 19 | minage: int = 0, maxage: int = 1000) -> "Survival": 20 | """Construct the basic survival and mortality functions given any one form 21 | 22 | Args: 23 | S : probability [x]+s survives t years 24 | f : or lifetime density function of [x]+s after t years 25 | l : or number of lives aged (x+t) 26 | mu : or force of mortality at age (x+t) 27 | maxage : maximum age 28 | minage : minimum age 29 | 30 | Examples: 31 | 32 | >>> B, c = 0.00027, 1.1 33 | >>> def S(x,s,t): return (math.exp(-B * c**(x+s) * (c**t - 1)/math.log(c))) 34 | >>> life = Survival().set_survival(S=S) 35 | 36 | >>> def ell(x,s): return (1 - (x+s) / 60)**(1 / 3) 37 | >>> life = Survival().set_survival(l=ell) 38 | """ 39 | assert(any([S, f, l, mu])), "One form of survival function must be specified" 40 | self._MAXAGE = maxage 41 | self._MINAGE = minage 42 | self.S = None # survival probability: Prob(T_[x]+s > t) 43 | self.f = None # lifetime density: f_[x]+s(t) ~ Prob[([x]+s) dies at t] 44 | self.l = None # number of lives aged [x]+s: l_[x]+s 45 | self.mu = None # force of mortality: mu_(x+t) 46 | 47 | def S_from_l(x: int, s, t: float) -> float: 48 | """Derive survival probability from number of lives""" 49 | return (self.l(x, s+t) / self.l(x, s)) if self.l(x, s) else 0. 50 | 51 | def mu_from_l(x: int, t: float) -> float: 52 | """Derive force of mortality from number of lives""" 53 | return -self.derivative(lambda s: self.l(x, s), t) / self.l(x,t) 54 | 55 | def f_from_l(x: int, s, t: float) -> float: 56 | """Derive lifetime density function from number of lives""" 57 | return -self.derivative(lambda t: self.l(x, s+t), t) 58 | 59 | def mu_from_S(x: int, t: float) -> float: 60 | """Derive force of mortality from survival probability""" 61 | return -self.derivative(lambda s: self.S(x, 0, s), t) / self.S(x,0,t) 62 | 63 | def f_from_S(x: int, s, t: float) -> float: 64 | """Derive lifetime density function from survival probability""" 65 | return -self.derivative(lambda t: self.S(x, s, t), t) 66 | 67 | def S_from_mu(x: int, s, t: float) -> float: 68 | """Derive survival probability from force of mortality""" 69 | return math.exp(-self.integral(lambda t: self.mu(x, s+t), 70 | lower=0, upper=t)) 71 | 72 | def S_from_f(x: int, s, t: float) -> float: 73 | """Derive survival probability from lifetime density function""" 74 | return 1 - self.integral(lambda t: self.f(x, s, t), lower=0, upper=t) 75 | 76 | def f_from_mu(x: int, s, t: float) -> float: 77 | """Derive lifetime density function from force of mortality""" 78 | return self.S(x, s, t) * self.mu(x, s+t) 79 | 80 | def mu_from_f(x: int, t: float) -> float: 81 | """Derive force of mortality from lifetime density function""" 82 | return self.f(x, 0, t) / self.S(x, 0, t) 83 | 84 | # derive and set all forms of basic survival and mortality functions 85 | if l is not None: 86 | assert callable(l), "l must be callable" 87 | self.S = S_from_l 88 | self.mu = mu_from_l 89 | self.f = f_from_l 90 | if S is not None: 91 | assert callable(S), "S must be callable" 92 | self.mu = mu_from_S 93 | self.f = f_from_S 94 | if mu is not None: 95 | assert callable(mu), "mu must be callable" 96 | self.S = S_from_mu 97 | self.f = f_from_mu 98 | if f is not None: 99 | assert callable(f), "f must be callable" 100 | self.S = S_from_f 101 | self.mu = mu_from_f 102 | self.l = l or self.l 103 | self.S = S or self.S 104 | self.f = f or self.f 105 | self.mu = mu or self.mu 106 | return self 107 | 108 | 109 | # 110 | # Actuarial forms of survival and mortality functions, at integer ages 111 | # 112 | def l_x(self, x: int, s: int = 0) -> float: 113 | """Number of lives at integer age [x]+s: l_[x]+s 114 | 115 | Args: 116 | x : age of selection 117 | s : years after selection 118 | """ 119 | assert x >= 0, "x must be non-negative" 120 | assert s >= 0, "s must be non-negative" 121 | if self.l is not None: 122 | return self.l(x, s) 123 | return self._RADIX * self.p_x(x=self._MINAGE, s=0, t=s+x-self._MINAGE) 124 | 125 | def d_x(self, x: int, s: int = 0) -> float: 126 | """Number of deaths at integer age [x]+s: d_[x]+s 127 | 128 | Args: 129 | x : age of selection 130 | s : years after selection 131 | """ 132 | assert x >= 0, "x must be non-negative" 133 | assert s >= 0, "s must be non-negative" 134 | return self.l_x(x=x, s=s) - self.l_x(x=x, s=s+1) 135 | 136 | def p_x(self, x: int, s: int = 0, t: int = 1) -> float: 137 | """Probability that [x]+s lives another t years: : t_p_[x]+s 138 | 139 | Args: 140 | x : age of selection 141 | s : years after selection 142 | t : survives at least t years 143 | """ 144 | assert x >= 0, "x must be non-negative" 145 | assert s >= 0, "s must be non-negative" 146 | if self.S is not None: 147 | return self.S(x, s, t) 148 | raise Exception("No functions implemented to compute survival") 149 | 150 | def q_x(self, x: int, s: int = 0, t: int = 1, u: int = 0) -> float: 151 | """Probability that [x]+s lives for u, but not t+u years: u|t_q_[x]+s 152 | 153 | Args: 154 | x : age of selection 155 | s : years after selection 156 | u : survives u years, then 157 | t : dies within next t years 158 | 159 | Examples: 160 | 161 | >>> def ell(x,s): return (1-((x+s)/250)) if x+s < 40 else (1-((x+s)/100)**2) 162 | >>> q = Survival().set_survival(l=ell).q_x(30, t=20) 163 | """ 164 | assert x >= 0, "x must be non-negative" 165 | assert s >= 0, "s must be non-negative" 166 | return self.p_x(x, s=s, t=u) - self.p_x(x, s=s, t=t+u) 167 | 168 | def f_x(self, x: int, s: int = 0, t: int = 0) -> float: 169 | """Lifetime density function of [x]+s after t years: f_[x]+s(t) 170 | 171 | Args: 172 | x : age of selection 173 | s : years after selection 174 | t : dies at year t 175 | 176 | Examples: 177 | 178 | >>> B, c = 0.00027, 1.1 179 | >>> def S(x,s,t): return (math.exp(-B * c**(x+s) * (c**t - 1)/math.log(c))) 180 | >>> f = Survival().set_survival(S=S).f_x(x=50, t=10) 181 | """ 182 | assert x >= 0, "x must be non-negative" 183 | assert s >= 0, "s must be non-negative" 184 | if self.f is not None: 185 | return self.f(x, s, t) 186 | return self.p_x(x, s=s, t=t) * self.mu_x(x, s=s, t=t) 187 | 188 | def mu_x(self, x: int, s: int = 0, t: int = 0) -> float: 189 | """Force of mortality of [x] at s+t years: mu_[x](s+t) 190 | 191 | Args: 192 | x : age of selection 193 | s : years after selection 194 | t : force of mortality at year t 195 | 196 | Examples: 197 | >>> def ell(x, s): return (1 - (x+s) / 60)**(1 / 3) 198 | >>> print(Survival().set_survival(l=ell).mu_x(35)) 199 | """ 200 | assert x >= 0, "x must be non-negative" 201 | assert s >= 0, "s must be non-negative" 202 | if self.mu is not None: 203 | return self.mu(x, s+t) 204 | return self.f_x(x, s=s, t=t) / self.p_x(x, s=s, t=t) 205 | 206 | # def survival_curve(self, x: int, s: int = 0, stop: int = 0) -> Tuple[List, List]: 207 | # """Construct curve of survival probabilities at each integer age 208 | # 209 | # Args: 210 | # x : age of selection 211 | # s : years after selection 212 | # stop : end at time t, inclusive 213 | # 214 | # Returns: 215 | # lists of lifetime and survival probability from t, S_x(t) respectively 216 | # """ 217 | # stop = stop or self._MAXAGE - (x + s) 218 | # steps = range(stop + 1) 219 | # return steps, [self.p_x(x=x, s=s, t=t) for t in steps] 220 | 221 | if __name__ == "__main__": 222 | print("SOA Question 2.3: (A) 0.0483") 223 | B, c = 0.00027, 1.1 224 | def S(x,s,t): return (math.exp(-B * c**(x+s) * (c**t - 1)/math.log(c))) 225 | f = Survival().set_survival(S=S).f_x(x=50, t=10) 226 | print(f) 227 | 228 | print("SOA Question 2.6: (C) 13.3") 229 | def ell(x,s): return (1 - (x+s) / 60)**(1 / 3) 230 | mu = Survival().set_survival(l=ell).mu_x(35) * 1000 231 | print(mu) 232 | 233 | print("SOA Question 2.7: (B) 0.1477") 234 | def ell(x,s): return (1 - ((x+s)/250)) if x+s < 40 else (1 - ((x+s)/100)**2) 235 | q = Survival().set_survival(l=ell).q_x(30, t=20) 236 | print(q) 237 | 238 | print("CAS41-F99:12: k = 41") 239 | def fun(k): 240 | return Survival().set_survival(l=lambda x,s: 100*(k - (x+s)/2)**(2/3))\ 241 | .mu_x(50) 242 | print(Survival.solve(fun, target=1/48, grid=50)) 243 | 244 | -------------------------------------------------------------------------------- /src/actuarialmath/constantforce.py: -------------------------------------------------------------------------------- 1 | """Constant Force of Mortality - shortcut formulas 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | from scipy.stats import norm 7 | from actuarialmath import MortalityLaws 8 | 9 | class ConstantForce(MortalityLaws): 10 | """Constant force of mortality - memoryless exponential distribution of lifetime 11 | 12 | Args: 13 | mu : constant value of force of mortality 14 | 15 | Examples: 16 | >>> life = ConstantForce(mu=0.01).set_interest(delta=0.05) 17 | >>> life.term_insurance(35, t=35, discrete=False) + life.E_x(35, t=35)*0.51791 18 | """ 19 | 20 | def __init__(self, mu: float, **kwargs): 21 | super().__init__(**kwargs) 22 | 23 | def _mu(x: int, s: float) -> float: 24 | """Constant force of mortality""" 25 | return mu 26 | 27 | def _S(x: int, s, t: float) -> float: 28 | """Shortcut for survival function with constant force of mortality""" 29 | return math.exp(-mu * t) 30 | 31 | self.set_survival(mu=_mu, S=_S) 32 | self.mu_ = mu # store mu parameter 33 | 34 | def e_x(self, x: int, s: int = 0, t: int = MortalityLaws.WHOLE, 35 | curtate: bool = False, moment: int = 1) -> float: 36 | """Expected lifetime E[T_x] is memoryless: does not depend on (x) 37 | 38 | Args: 39 | x : age of selection 40 | s : years after selection 41 | t : limited at year t 42 | curtate : whether curtate (True) or continuous (False) lifetime 43 | moment : first (1) or second (2) moment 44 | """ 45 | if not curtate: # Var[Tx] = 1/mu^2, E[Tx] = 1/mu 46 | if moment == MortalityLaws.VARIANCE: 47 | return 1. / self.mu_**2 # shortcut 48 | elif moment == 1: 49 | e = 1 / self.mu_ # infinite n case shortcut 50 | if t >= 0: # n-limited from recursion e_x=e_x|n + n_p_x e_x 51 | e *= (1 - math.exp(-self.mu_ * t)) 52 | return e 53 | return super().e_x(x, s=s, t=t, curtate=curtate, moment=moment) 54 | 55 | def E_x(self, x: int, s: int = 0, t: int = MortalityLaws.WHOLE, 56 | endowment: int = 1, moment: int = 1) -> float: 57 | """Shortcut for pure endowment: does not depend on age x 58 | 59 | Args: 60 | x : age of selection 61 | s : years after selection 62 | t : term of pure endowment 63 | endowment : amount of pure endowment 64 | moment : compute first or second moment 65 | """ 66 | if t == 0: 67 | return 1. 68 | if t < 0: 69 | return 0. 70 | delta = moment * self.interest.delta # multiply force of interest 71 | return math.exp(-(self.mu_ + delta) * t) * endowment**moment 72 | 73 | def whole_life_annuity(self, x: int, s: int = 0, b: int = 1, 74 | variance: bool = False, 75 | discrete: bool = True) -> float: 76 | """Shortcut for whole life annuity: does not depend on age x 77 | 78 | Args: 79 | x : age of selection 80 | s : years after selection 81 | b : annuity benefit amount 82 | variance : return APV (True) or variance (False) 83 | discrete : annuity due (True) or continuous (False) 84 | """ 85 | if variance: # short cut for variance of temporary life annuity 86 | A1 = self.whole_life_insurance(x, s=s, discrete=discrete) 87 | A2 = self.whole_life_insurance(x, s=s, moment=2, discrete=discrete) 88 | return (b**2 * (A2 - A1**2) / 89 | (self.interest.d if discrete else self.interest.delta)**2) 90 | if not discrete: 91 | den = self.mu_ + self.interest.delta 92 | return (1. / den) * b if den > 0 else math.inf 93 | return super().whole_life_annuity(x, s=s, b=b, discrete=discrete) 94 | 95 | def temporary_annuity(self, x: int, s: int = 0, t: int = MortalityLaws.WHOLE, 96 | b: int = 1, variance: bool = False, 97 | discrete: bool = True) -> float: 98 | """Shortcut for temporary life annuity: does not depend on age x 99 | 100 | Args: 101 | x : age of selection 102 | s : years after selection 103 | t : term of annuity in years 104 | b : annuity benefit amount 105 | variance : return APV (True) or variance (False) 106 | discrete : annuity due (True) or continuous (False) 107 | """ 108 | interest = self.interest.d if discrete else self.interest.delta 109 | if variance: # short cut for variance of temporary life annuity 110 | A1 = self.endowment_insurance(x, s=s, t=t, discrete=discrete) 111 | A2 = self.endowment_insurance(x, s=s, t=t, moment=2, 112 | discrete=discrete) 113 | return (b**2 * (A2 - A1**2) / 114 | (self.interest.d if discrete else self.interest.delta)**2) 115 | if not discrete: 116 | a = b * 1/(self.mu_ + self.interest.delta) 117 | if t < 0: 118 | return a 119 | else: 120 | return a * (1 - math.exp(-(self.mu_ + self.interest.delta)*t)) 121 | return super().temporary_annuity(x, s=s, b=b, t=t, discrete=discrete) 122 | 123 | def whole_life_insurance(self, x: int, s: int = 0, moment: int = 1, 124 | b: int = 1, discrete: bool = True) -> float: 125 | """Shortcut for APV of whole life: does not depend on age x 126 | 127 | Args: 128 | x : age of selection 129 | s : years after selection 130 | b : amount of benefit 131 | moment : compute first or second moment 132 | discrete : benefit paid year-end (True) or moment of death (False) 133 | """ 134 | if moment > 0 and not discrete: 135 | delta = moment * self.interest.delta # multiply force of interest 136 | return self.mu_ / (self.mu_ + delta) if self.mu_ > 0 else 0. 137 | return super().whole_life_insurance(x, s=s, moment=moment, b=b, 138 | discrete=discrete) 139 | 140 | def term_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 141 | moment: int = 1, discrete: bool = True) -> float: 142 | """Shortcut for APV of term life: does not depend on age x 143 | 144 | Args: 145 | x : age of selection 146 | s : years after selection 147 | t : term of insurance 148 | b : amount of benefit 149 | moment : compute first or second moment 150 | discrete : benefit paid year-end (True) or moment of death (False) 151 | """ 152 | if moment > 0 and not discrete: 153 | delta = moment * self.interest.delta # multiply force of interest 154 | A = b**moment * self.mu_/(self.mu_ + delta) 155 | if t < 0: 156 | return A 157 | else: 158 | return A * (1 - math.exp(-(self.mu_ + delta)*t)) 159 | return super().term_insurance(x, s=s, t=t, b=b, moment=moment, 160 | discrete = discrete) 161 | 162 | def Z_t(self, x: int, prob: float, discrete: bool = True) -> float: 163 | """Shortcut for T_x (or K_x) given survival probability for insurance 164 | 165 | Args: 166 | x : age (does not depend) 167 | prob : desired probability threshold 168 | discrete : benefit paid year-end (True) or moment of death (False) 169 | """ 170 | t = -math.log(prob) / self.mu_ 171 | return math.floor(t) if discrete else t # opposite of annuity 172 | 173 | def Y_t(self, x: int, prob: float, discrete: bool = True) -> float: 174 | """Shortcut for T_x (or K_x) given survival probability for annuity 175 | 176 | Args: 177 | x : age (does not depend) 178 | prob: desired probability threshold 179 | discrete : continuous (False) or annuity due (True) 180 | """ 181 | t = -math.log(1 - prob) / self.mu_ 182 | return math.ceil(t) if discrete else t # opposite of insurance 183 | 184 | if __name__ == "__main__": 185 | print("SOA Question 6.36: (B) 500") 186 | life = ConstantForce(mu=0.04).set_interest(delta=0.08) 187 | a = life.temporary_annuity(50, t=20, discrete=False) 188 | A = life.term_insurance(50, t=20, discrete=False) 189 | print(a,A) 190 | def fun(R): 191 | return life.gross_premium(a=a, A=A, initial_premium=R/4500, 192 | renewal_premium=R/4500, benefit=100000) 193 | R = life.solve(fun, target=4500, grid=[400, 800]) 194 | print(R) 195 | print() 196 | 197 | print("SOA Question 6.31: (D) 1330") 198 | life = ConstantForce(mu=0.01).set_interest(delta=0.05) 199 | A = life.term_insurance(35, t=35) + life.E_x(35, t=35) * 0.51791 # A_35 200 | A = (life.term_insurance(35, t=35, discrete=False) 201 | + life.E_x(35, t=35) * 0.51791) # A_35 202 | P = life.premium_equivalence(A=A, b=100000, discrete=False) 203 | print(P) 204 | print() 205 | 206 | print("SOA Question 6.27: (D) 10310") 207 | life = ConstantForce(mu=0.03).set_interest(delta=0.06) 208 | x = 0 209 | payments = (3 * life.temporary_annuity(x, t=20, discrete=False) 210 | + life.deferred_annuity(x, u=20, discrete=False)) 211 | benefits = (1000000 * life.term_insurance(x, t=20, discrete=False) 212 | + 500000 * life.deferred_insurance(x, u=20, discrete=False)) 213 | print(benefits, payments) 214 | print(life.term_insurance(x, t=20), life.deferred_insurance(x, u=20)) 215 | P = benefits / payments 216 | print(P) 217 | print() 218 | 219 | 220 | print("SOA Question 5.4: (A) 213.7") 221 | life = ConstantForce(mu=0.02).set_interest(delta=0.01) 222 | P = 10000 / life.certain_life_annuity(40, u=life.e_x(40, curtate=False), 223 | discrete=False) 224 | print(P) 225 | print() 226 | 227 | 228 | print("SOA Question 5.1: (A) 0.705") 229 | life = ConstantForce(mu=0.01).set_interest(delta=0.06) 230 | EY = life.certain_life_annuity(0, u=10, discrete=False) 231 | print(life.p_x(0, t=life.Y_to_t(EY))) # 0.705 232 | print() 233 | 234 | -------------------------------------------------------------------------------- /src/actuarialmath/premiums.py: -------------------------------------------------------------------------------- 1 | """Premiums - Computes net and gross premiums, with the equivalence principle 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | from actuarialmath import Annuity 6 | 7 | class Premiums(Annuity): 8 | """Compute et and gross premiums under equivalence principle""" 9 | 10 | # 11 | # Net level premiums for special insurances 12 | # 13 | def net_premium(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 14 | u: int = 0, n: int = 0, b: int = 1, 15 | endowment: int = 0, discrete: bool | None = True, 16 | return_premium: bool = False, annuity: bool = False, 17 | initial_cost: float = 0.) -> float: 18 | """Net level premium for special n-pay, u-deferred t-year term insurance 19 | 20 | Args: 21 | x : age initially insured 22 | s : years after selection 23 | u : years of deferral 24 | n : number of years of premiums paid 25 | t : year of death 26 | b : benefit amount 27 | endowment : endowment amount 28 | initial_cost : EPV of any other expenses or benefits, if any 29 | return_premium : if premiums without interest refunded at death 30 | annuity : whether benefit is insurance (False) or annuity 31 | discrete : whether annuity due (True) or continuous (False) 32 | 33 | Examples: 34 | >>> life = Premiums().set_interest(delta=0.06)\ 35 | >>> .set_survival(mu=lambda x,s: 0.04) 36 | >>> life.net_premium(x=0) 37 | 38 | """ 39 | if annuity: 40 | A = self.deferred_annuity(x, s=s, b=b, t=t, u=u, 41 | discrete=discrete or discrete is None) 42 | else: 43 | A = self.deferred_insurance(x, s=s, b=b, t=t, u=u, 44 | discrete=discrete or discrete is None) 45 | if endowment: 46 | A += self.E_x(x, s=s, t=t+u) * endowment 47 | if n == 0: # if n not specified 48 | n = u or t # then set to defer period if any, else same term t 49 | discrete = discrete or discrete is None # discrete or semi-discrete 50 | a = self.temporary_annuity(x, s=s, t=n, discrete=discrete) 51 | if return_premium: # death benefit include premiums returned w/o interest 52 | a -= self.increasing_insurance(x, s=s, t=n, discrete=discrete) 53 | return (A + initial_cost) / a 54 | 55 | # 56 | # Equivalence principle for WL, Endowment Insurance, and their Annuity twins 57 | # 58 | def insurance_equivalence(self, premium: float, b: int = 1, 59 | discrete: bool = True) -> float: 60 | """Compute whole life or endowment insurance factor, given net premium 61 | 62 | Args: 63 | premium : level net premium amount 64 | b : benefit amount 65 | discrete : discrete/annuity due (True) or continuous (False) 66 | 67 | Returns: 68 | Insurance factor value, given net premium under equivalence principle 69 | 70 | Examples: 71 | >>> life = Premiums().set_interest(d=0.05) 72 | >>> life.insurance_equivalence(premium=2143, b=100000) 73 | """ 74 | d = self.interest.d if discrete else self.interest.delta 75 | return premium / (d*b + premium) # from P = b[dA/(1-A)] 76 | 77 | def annuity_equivalence(self, premium: float, b: int = 1, 78 | discrete: bool = True) -> float: 79 | """Compute whole life or temporary annuity factor, given net premium 80 | 81 | Args: 82 | premium : level net premium amount 83 | b : benefit amount 84 | discrete : discrete/annuity due (True) or continuous (False) 85 | 86 | Returns: 87 | Annuity factor value, given net premium under equivalence principle 88 | 89 | Examples: 90 | >>> life = Premiums().set_interest(d=0.05) 91 | >>> a = life.annuity_equivalence(premium=2143, b=100000) 92 | """ 93 | d = self.interest.d if discrete else self.interest.delta 94 | return b / (d*b + premium) # from P = b * (1/a - d) 95 | 96 | def premium_equivalence(self, 97 | A: float | None = None, 98 | a: float | None = None, 99 | b: int = 1, discrete: bool = True) -> float: 100 | """Compute premium from whole life or endowment insurance and annuity factors 101 | 102 | Args: 103 | A : insurance factor 104 | a : annuity factor 105 | b : insurance benefit amount 106 | discrete : annuity due (True) or continuous (False) 107 | 108 | Returns: 109 | Net premium under equivalence principle 110 | """ 111 | interest = self.interest.d if discrete else self.interest.delta 112 | if a is None: # Annuity not given => use shortcut for insurance 113 | return b * interest * A / (1 - A) 114 | elif A is None: # Insurance not given => use shortcut for annuity 115 | return b * (1/a - interest) 116 | else: # Both (special) insurance and annuity factors given 117 | return b * A / a 118 | 119 | # 120 | # Gross premiums for special insurances 121 | # 122 | def gross_premium(self, 123 | a: float | None = None, 124 | A: float | None = None, IA: float = 0, 125 | discrete: bool = True, benefit: float = 1, 126 | E: float = 0, endowment: int = 0, 127 | settlement_policy: float = 0., 128 | initial_policy: float = 0., 129 | initial_premium: float = 0., 130 | renewal_policy: float = 0., 131 | renewal_premium: float = 0.) -> float: 132 | """Gross premium by equivalence principle 133 | 134 | Args: 135 | A : insurance factor 136 | a : annuity factor 137 | IA : increasing insurance factor, to return premiums w/o interest 138 | E : pure endowment factor for endowment benefit 139 | benefit : insurance benefit amount 140 | endowment : endowment benefit amount 141 | settlement_policy : settlement expense per policy 142 | initial_policy : initial expense per policy 143 | renewal_policy : renewal expense per policy 144 | initial_premium : initial premium per $ of gross premium 145 | renewal_premium : renewal premium per $ of gross premium 146 | discrete : annuity due (True) or continuous (False) 147 | 148 | Examples: 149 | >>> Premiums().gross_premium(a=0.17094, 150 | >>> A=6.8865, 151 | >>> IA= 0.96728, 152 | >>> benefit=100000, 153 | >>> initial_premium=0.5, 154 | >>> renewal_premium=.05, 155 | >>> renewal_policy=200, 156 | >>> initial_policy=200) 157 | """ 158 | if a is None: # assume WL or Endowment Insurance for twin 159 | a = self.annuity_twin(A, discrete=discrete) 160 | elif A is None: # assume WL or Endowment Insurance for twin 161 | A = self.insurance_twin(a, discrete=discrete) 162 | 163 | assert endowment == 0 or E > 0 # missing pure endowment factor if needed 164 | per_premium = renewal_premium * a + (initial_premium - renewal_premium) 165 | per_policy = renewal_policy * a + (initial_policy - renewal_policy) 166 | return (((A*(benefit + settlement_policy) + per_policy) + (E*endowment)) 167 | / (a - per_premium - IA)) # IA returns premium w/o interest 168 | 169 | 170 | if __name__ == "__main__": 171 | import numpy as np 172 | 173 | print("SOA Question 6.29 (B) 20.5") 174 | life = Premiums().set_interest(i=0.035) 175 | def fun(a): 176 | return life.gross_premium(A=life.insurance_twin(a=a), 177 | a=a, 178 | benefit=100000, 179 | initial_policy=200, 180 | initial_premium=.5, 181 | renewal_policy=50, 182 | renewal_premium=.1) 183 | print(life.solve(fun, target=1770, grid=[20, 22])) 184 | print() 185 | 186 | print("SOA Question 6.2: (E) 3604") 187 | life = Premiums() 188 | A, IA, a = 0.17094, 0.96728, 6.8865 189 | print(life.gross_premium(a=a, 190 | A=A, 191 | IA=IA, 192 | benefit=100000, 193 | initial_premium=0.5, 194 | renewal_premium=.05, 195 | renewal_policy=200, 196 | initial_policy=200)) 197 | print() 198 | 199 | print("SOA Question 6.16: (A) 2408.6") 200 | life = Premiums().set_interest(d=0.05) 201 | A = life.insurance_equivalence(premium=2143, b=100000) 202 | a = life.annuity_equivalence(premium=2143, b=100000) 203 | p = life.gross_premium(A=A, 204 | a=a, 205 | benefit=100000, 206 | settlement_policy=0, 207 | initial_policy=250, 208 | initial_premium=0.04 + 0.35, 209 | renewal_policy=50, 210 | renewal_premium=0.04 + 0.02) 211 | print(A, a, p) 212 | print() 213 | 214 | print("SOA Question 6.20: (B) 459") 215 | l = lambda x,s: dict(zip([75, 76, 77, 78], 216 | np.cumprod([1, .9, .88, .85]))).get(x+s, 0) 217 | life = Premiums().set_interest(i=0.04).set_survival(l=l) 218 | a = life.temporary_annuity(75, t=3) 219 | IA = life.increasing_insurance(75, t=2) 220 | A = life.deferred_insurance(75, u=2, t=1) 221 | print(life.solve(lambda P: P*IA + A*10000 - P*a, target=0, grid=100)) 222 | print() 223 | 224 | print("Other usage") 225 | life = Premiums().set_interest(delta=0.06)\ 226 | .set_survival(mu=lambda x,s: 0.04) 227 | print(life.net_premium(0)) 228 | 229 | print("SOA Question 5.6: (D) 1200") 230 | life = Premiums().set_interest(i=0.05) 231 | var = life.annuity_variance(A2=0.22, A1=0.45) 232 | mean = life.annuity_twin(A=0.45) 233 | fund = life.portfolio_percentile(mean, var, prob=.95, N=100) 234 | print(fund) 235 | -------------------------------------------------------------------------------- /src/actuarialmath/fractional.py: -------------------------------------------------------------------------------- 1 | """Fractional age assumptions- Computes survival and mortality functions between integer ages. 2 | 3 | MIT License. Copyright (c) 2022-2023 Terence Lim 4 | """ 5 | 6 | import math 7 | from actuarialmath import Lifetime 8 | 9 | class Fractional(Lifetime): 10 | """Compute survival functions at fractional ages and durations 11 | 12 | Args: 13 | udd : select UDD (True, default) or CFM (False) between integer ages 14 | """ 15 | 16 | def __init__(self, udd: bool = True, **kwargs): 17 | super().__init__(**kwargs) 18 | self.udd_ = udd 19 | 20 | # 21 | # Define actuarial forms of survival functions at fractional ages 22 | # 23 | def l_r(self, x: int, s: int = 0, r: float = 0.) -> float: 24 | """Number of lives at fractional age: l_[x]+s+r 25 | 26 | Args: 27 | x : age of selection 28 | s : years after selection 29 | r : fractional year after selection 30 | """ 31 | assert x >= 0, "x must be non-negative" 32 | assert s >= 0, "s must be non-negative" 33 | assert r >= 0, "r must be non-negative" 34 | s += math.floor(r) # interpolate lives between consecutive integer ages 35 | r -= math.floor(r) 36 | if self.isclose(r): 37 | return self.l_x(x, s=s) 38 | if self.isclose(r, 1.0): 39 | return self.l_x(x, s=s+1) 40 | if self.udd_: 41 | return self.l_x(x, s=s)*(1-r) + self.l_x(x, s=s+1)*r 42 | else: 43 | return self.l_x(x, s=s)**(1-r) * self.l_x(x, s=s+1)**r 44 | 45 | def p_r(self, x: int, s: int = 0, r: float = 0., t: float = 1.) -> float: 46 | """Probability of survival from and through fractional age: t_p_[x]+s+r 47 | 48 | Args: 49 | x : age of selection 50 | s : years after selection 51 | r : fractional year after selection 52 | t : fractional number of years survived 53 | 54 | Examples: 55 | 56 | >>> life = Fractional(udd=False).set_survival(l=lambda x,t: 50-x-t) 57 | >>> print(life.p_r(47, r=0.), life.p_r(47, r=0.5), life.p_r(47, r=1.)) 58 | 59 | """ 60 | assert x >= 0, "x must be non-negative" 61 | assert s >= 0, "s must be non-negative" 62 | assert r >= 0, "r must be non-negative" 63 | assert t >= 0, "t must be non-negative" 64 | r_floor = math.floor(r) 65 | s += r_floor 66 | r -= r_floor 67 | if 0. <= r + t <= 1.: 68 | if self.udd_: 69 | return 1 - self.q_r(x, s=s, r=r, t=t) 70 | else: # Constant force shortcut within int age 71 | return self.p_x(x, s=s)**t # does not depend on r 72 | return self.l_r(x, s=s, r=r+t) / self.l_r(x, s=s, r=r) 73 | 74 | def q_r(self, x: int, s: int = 0, r: float = 0., t: float = 1., 75 | u: float = 0.) -> float: 76 | """Deferred mortality rate within fractional ages: u|t_q_[x]+s+r 77 | 78 | Args: 79 | x : age of selection 80 | s : years after selection 81 | r : fractional year after selection 82 | u : fractional number of years survived, then 83 | t : death within next fractional years t 84 | 85 | Examples: 86 | >>> life = Fractional(udd=False).set_survival(l=lambda x,t: 50-x-t) 87 | >>> print(life.q_r(x, r=0.5)) 88 | 89 | """ 90 | assert x >= 0, "x must be non-negative" 91 | assert s >= 0, "s must be non-negative" 92 | assert r >= 0, "r must be non-negative" 93 | assert t >= 0, "t must be non-negative" 94 | assert t >= 0, "t must be non-negative" 95 | r_floor = math.floor(r) 96 | s += r_floor 97 | r -= r_floor 98 | if 0 <= r + t + u <= 1: 99 | if u > 0: # Die within u|t_q == Die in t+u but not in u 100 | return self.q_r(x, s=s, r=r, t=u+t) - self.q_r(x, s=s, r=r, t=u) 101 | if self.udd_: # UDD shortcut within integer age 102 | return (t * self.q_x(x, s=s)) / (1. - r * self.q_x(x, s=s)) 103 | else: 104 | return 1 - self.p_r(x, s=s, r=r, t=t) 105 | return ((self.l_r(x, s=s, r=r+u) - self.l_r(x, s=s, r=r+u+t)) 106 | / self.l_r(x, s=s, r=r)) 107 | 108 | def mu_r(self, x: int, s: int = 0, r: float = 0.) -> float: 109 | """Force of mortality at fractional age: mu_[x]+s+r 110 | 111 | Args: 112 | x : age of selection 113 | s : years after selection 114 | r : fractional year after selection 115 | """ 116 | assert x >= 0, "x must be non-negative" 117 | assert s >= 0, "s must be non-negative" 118 | assert r >= 0, "r must be non-negative" 119 | r_floor = math.floor(r) 120 | s += r_floor 121 | r -= r_floor 122 | if self.isclose(r): 123 | return self.mu_x(x, s=s) 124 | if self.udd_: # UDD shortcut 125 | return self.q_x(x, s=s) / (1. - r*self.q_x(x, s=s)) 126 | else: # Constant force shortcut 127 | return -math.log(max(0.000001, self.p_x(x, s=s))) 128 | 129 | def f_r(self, x: int, s: int = 0, r: float = 0., t: float = 0.0) -> float: 130 | """Lifetime density function at fractional age: f_[x]+s+r (t) 131 | 132 | Args: 133 | x : age of selection 134 | s : years after selection 135 | r : fractional year after selection 136 | t : death at fractional year t 137 | """ 138 | assert x >= 0, "x must be non-negative" 139 | assert s >= 0, "s must be non-negative" 140 | assert r >= 0, "r must be non-negative" 141 | assert t >= 0, "t must be non-negative" 142 | if 0. <= r + t <= 1.: # shortcuts available within integer ages 143 | if self.udd_: # UDD shortcut: constant q_x 144 | return self.q_x(x, s=s) # does not depend fractional age or duration 145 | else: # Constant force shortcut: 146 | if self.isclose(t): 147 | return self.f_x(x=x, s=s) 148 | mu = -math.log(max(0.00001, self.p_x(x, s=s))) 149 | return math.exp(-mu*t) * mu # does not depend on fractional age 150 | else: # else survive to integer age, times density at fractional age 151 | r_floor = math.floor(r) 152 | r -= r_floor # s.t. r < 1 153 | s += r_floor # while maintaining x+s+r unchanged 154 | t_floor = math.floor(r + t) 155 | u = t_floor - r # s.t. u + r is integer 156 | t = t + r - t_floor # s.t. t < 1 157 | return self.p_r(x, s=s, r=r, t=u) * self.f_r(x, s=s+u+r, t=t) 158 | 159 | # 160 | # Define fractional age pure endowment function 161 | # 162 | 163 | def E_r(self, x: int, s: int = 0, r: float = 0., t: float = 1.) -> float: 164 | """Pure endowment at fractional age: t_E_[x]+s+r 165 | 166 | Args: 167 | x : age of selection 168 | s : years after selection 169 | r : fractional year after selection 170 | t : limited at fractional year t 171 | """ 172 | assert x >= 0, "x must be non-negative" 173 | assert s >= 0, "s must be non-negative" 174 | assert r >= 0, "r must be non-negative" 175 | assert t >= 0, "t must be non-negative" 176 | return self.p_r(x, s=s, r=r, t=t) * self.interest.v_t(t) 177 | 178 | # 179 | # Define fractional age expectations of future lifetime 180 | # 181 | def e_r(self, x: int, s: int = 0, t: float = Lifetime.WHOLE) -> float: 182 | """Temporary expected future lifetime at fractional age: e_[x]+s:t 183 | 184 | Args: 185 | x : age of selection 186 | s : years after selection 187 | t : fractional year limit of expected future lifetime 188 | """ 189 | assert x >= 0, "x must be non-negative" 190 | assert s >= 0, "s must be non-negative" 191 | if t == 0: 192 | return 0 193 | 194 | # shortcuts for complete expectation 195 | elif t < 0: 196 | if self.udd_: # UDD case 197 | return self.e_x(x=x, s=s, t=t, curtate=True) + 0.5 198 | else: # Constant Force: compute as temporary through maxage 199 | return self.e_r(x=x, s=s, t=self.max_term(x+s, t)) 200 | 201 | # shortcuts between integer ages 202 | elif t <= 1: 203 | if self.udd_: # UDD case 204 | if t == 1: # shortcut for UDD 1-year limited expectation 205 | return 1. - self.q_x(x=x, s=s)*(1/2) 206 | else: # shortcut for fractional limited expectation 207 | return self.q_r(x=x, s=s, t=t)*(t/2) + self.p_r(x, s=s, t=t)*t 208 | else: # Constant Force case 209 | mu = -math.log(max(0.00001, self.p_x(x=x, s=s))) # constant mu 210 | return (1. - math.exp(-mu*t)) / mu # shortcut 211 | 212 | # t > 1: apply one-year recursion formula 213 | else: 214 | return (self.e_r(x=x, s=s, t=1) + 215 | (self.p_x(x=x, s=s) * self.e_r(x=x, s=s+1, t=t-1))) 216 | 217 | # 218 | # Approximation of curtate and complete lifetimes 219 | # 220 | @staticmethod 221 | def e_approximate(e_complete: float = None, e_curtate: float = None, 222 | variance: bool = False) -> float: 223 | """Convert between curtate and complete expectations assuming UDD shortcut 224 | 225 | Args: 226 | e_complete : complete expected lifetime 227 | e_curtate : or curtate expected lifetime 228 | variance : to approximate mean (False) or variance (True) 229 | 230 | Returns: 231 | approximate complete or curtate expectation assuming UDD 232 | 233 | Examples: 234 | >>> print(Fractional.e_approximate(e_complete=15)) 235 | >>> print(Fractional.e_approximate(e_curtate=15)) 236 | """ 237 | if e_complete is not None: 238 | assert e_curtate is None, "one of e and e_curtate must be None" 239 | return e_complete - (1/12 if variance else 0.5) 240 | elif e_curtate is not None: 241 | return e_curtate + (1/12 if variance else 0.5) 242 | else: 243 | raise Exception("Provide a value for either e_complete or e_curtate") 244 | 245 | if __name__ == "__main__": 246 | print(Fractional.e_approximate(e_complete=15)) # output e_curtate 247 | print(Fractional.e_approximate(e_curtate=15)) # output e_complete 248 | 249 | x = 45 250 | life = Fractional(udd=False).set_survival(l=lambda x,t: 50-x-t) 251 | print(life.q_r(x, r=0.), life.q_r(x, r=0.5), life.q_r(x, r=1.)) 252 | life = Fractional(udd=True).set_survival(l=lambda x,t: 50-x-t) 253 | print(life.q_r(x, r=0.), life.q_r(x, r=0.5), life.q_r(x, r=1.)) 254 | -------------------------------------------------------------------------------- /src/actuarialmath/udd.py: -------------------------------------------------------------------------------- 1 | """UDD - 1/mthly insurance and annuities with uniform distribution of deaths 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import pandas as pd 6 | from actuarialmath import Interest 7 | from actuarialmath import Annuity 8 | from actuarialmath import Mthly 9 | 10 | class UDD(Mthly): 11 | """1/mthly shortcuts with UDD assumption 12 | 13 | Args: 14 | m : number of payments per year 15 | life : original fractional survival and mortality functions 16 | """ 17 | 18 | def __init__(self, m: int, life: Annuity, **kwargs): 19 | super().__init__(m=m, life=life, **kwargs) 20 | self.alpha_m = self.alpha(m=m, i=self.life.interest.i) 21 | self.beta_m = self.beta(m=m, i=self.life.interest.i) 22 | 23 | @staticmethod 24 | def alpha(m: int, i: float) -> float: 25 | """Compute 1/mthly UDD interest rate beta function value 26 | 27 | Args: 28 | m : number of payments per year 29 | i : annual interest rate 30 | """ 31 | d = i / (1 + i) 32 | i_m = Interest.mthly(m=m, i=i) 33 | i_d = Interest.mthly(m=m, d=d) 34 | return abs(i*d / ( i_m * i_d)) 35 | 36 | @staticmethod 37 | def beta(m: int, i: float) -> float: 38 | """Compute 1/mthly UDD interest rate alpha function value 39 | 40 | Args: 41 | m : number of payments per year 42 | i : annual interest rate 43 | """ 44 | d = i / (1 + i) 45 | i_m = Interest.mthly(m=m, i=i) 46 | i_d = Interest.mthly(m=m, d=d) 47 | return abs((i - i_m) / (i_m * i_d)) 48 | 49 | def whole_life_insurance(self, x: int, s: int = 0, moment: int = 1, 50 | b: int = 1) -> float: 51 | """1/mthly UDD Whole life insurance: A_x 52 | 53 | Args: 54 | x : age of selection 55 | s : years after selection 56 | b : amount of benefit 57 | moment : compute first or second moment 58 | 59 | Examples: 60 | >>> UDD(m=0, life=SULT(udd=True)).whole_life_insurance(x) 61 | """ 62 | assert moment in [1, 2, Annuity.VARIANCE] 63 | if moment == Annuity.VARIANCE: 64 | return ((self.whole_life_insurance(x, s=s, moment=2) - 65 | self.whole_life_insurance(x, s=s)**2) * b**2) 66 | A = self.life.whole_life_insurance(x, s=s, moment=moment) 67 | i = self.life.interest.i 68 | if moment == 2: 69 | i = self.interest.double_force(i) 70 | i_m = self.life.interest.mthly(m=self.m, i=i) 71 | return A * i / i_m 72 | 73 | def endowment_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 74 | endowment: int = -1, moment: int = 1) -> float: 75 | """1/mthly UDD Endowment insurance = term insurance + pure endowment 76 | 77 | Args: 78 | x : year of selection 79 | s : years after selection 80 | t : term of insurance in years 81 | b : amount of benefit 82 | endowment : amount of endowment 83 | moment : return first or second moment 84 | """ 85 | assert moment in [1, 2, Annuity.VARIANCE] 86 | if moment == Annuity.VARIANCE: 87 | return (self.endowment_insurance(x, s=s, t=t, endowment=endowment, 88 | b=b, moment=2) - 89 | self.endowment_insurance(x, s=s, t=t, endowment=endowment, 90 | b=b)**2) 91 | E = self.E_x(x, s=s, t=t, moment=moment) 92 | A = self.term_insurance(x, s=s, t=t, b=b, moment=moment) 93 | return A + E * (b if endowment < 0 else endowment)**moment 94 | 95 | 96 | def term_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 97 | moment: int = 1) -> float: 98 | """1/mthly UDD Term insurance: A_x:t 99 | 100 | Args: 101 | x : year of selection 102 | s : years after selection 103 | t : term of insurance in years 104 | b : amount of benefit 105 | moment : return first or second moment 106 | 107 | Examples: 108 | >>> UDD(m=12, life=SULT(udd=True)).term_insurance(45) 109 | """ 110 | assert moment in [1, 2, Annuity.VARIANCE] 111 | if moment == Annuity.VARIANCE: 112 | return (self.term_insurance(x, s=s, t=t, b=b, moment=2) - 113 | self.term_insurance(x, s=s, t=t, b=b)**2) 114 | A = self.life.term_insurance(x, s=s, t=t, b=b, moment=moment) 115 | i = self.life.interest.i 116 | if moment == 2: 117 | i = self.interest.double_force(i) 118 | i_m = self.life.interest.mthly(m=self.m, i=i) 119 | return A * (i / i_m) 120 | 121 | def whole_life_annuity(self, x: int, s: int = 0, b: int = 1, 122 | variance: bool = False) -> float: 123 | """1/mthly UDD Whole life annuity: a_x 124 | 125 | Args: 126 | x : year of selection 127 | s : years after selection 128 | b : amount of benefit 129 | variance : return first moment (False) or variance (True) 130 | 131 | Examples: 132 | >>> UDD(m=12, life=SULT(udd=True)).whole_life_annuity(x) 133 | """ 134 | if variance: # short cut for variance of whole life 135 | A1 = self.whole_life_insurance(x, s=s, moment=1) 136 | A2 = self.whole_life_insurance(x, s=s, moment=2) 137 | return (b**2 * (A2 - A1**2) / self.d**2) 138 | a = self.life.whole_life_annuity(x, s=s) 139 | return b * (a * self.alpha_m - self.beta_m) 140 | 141 | def temporary_annuity(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 142 | b: int = 1, variance: bool = False) -> float: 143 | """1/mthly UDD Temporary life annuity: a_x:t 144 | 145 | Args: 146 | x : year of selection 147 | s : years after selection 148 | t : term of annuity in years 149 | b : amount of benefit 150 | variance : return first moment (False) or variance (True) 151 | 152 | Examples: 153 | >>> UDD(m=12, life=SULT(udd=True)).temporary_annuity(45, t=20) 154 | """ 155 | if variance: # short cut for variance of temporary life annuity 156 | A1 = self.temporary_insurance(x, s=s, t=t) 157 | A2 = self.temporary_insurance(x, s=s, t=t, moment=2) 158 | return (b**2 * (A2 - A1**2) / self.d**2) 159 | 160 | # difference of whole life on (x) and deferred whole life on (x+t) 161 | if t < 0: 162 | return self.whole_life_annuity(x, b=b, s=s) # UDD 163 | a = self.life.temporary_annuity(x, s=s, t=t) 164 | return b * (a*self.alpha_m - self.beta_m*(1 - self.E_x(x, s=s, t=t))) 165 | 166 | def deferred_annuity(self, x: int, s: int = 0, u: int = 0, 167 | t: int = Annuity.WHOLE, b: int = 1, 168 | variance: bool = False) -> float: 169 | """1/mthly UDD Deferred life annuity n|t_a_x = n+t_a_x - n_a_x 170 | 171 | Args: 172 | x : year of selection 173 | s : years after selection 174 | u : years of deferral 175 | t : term of annuity in years 176 | b : amount of benefit 177 | """ 178 | if self.max_term(x+s, n) < n: 179 | return 0. 180 | if variance: # short cut for variance of temporary life annuity 181 | A1 = self.endowment_insurance(x, s=s, t=t) 182 | A2 = self.endowment_insurance(x, s=s, t=t, moment=2) 183 | return (b**2 * (A2 - A1**2) / self.d**2) 184 | 185 | a = self.life.deferred_annuity(x, s=s, u=u, t=t) 186 | return (a * self.alpha_m - self.beta_m * self.E_x(x, s=s, t=u)) * b 187 | 188 | @staticmethod 189 | def interest_frame(i: float = 0.05): 190 | """Return 1/mthly UDD interest function values in a DataFrame 191 | 192 | Args: 193 | i : annual interest rate 194 | """ 195 | interest = Interest(i=i) 196 | out = pd.DataFrame(columns=["i(m)", "d(m)", "i/i(m)", "d/d(m)", 197 | "alpha(m)", "beta(m)"], 198 | index=[1, 2, 4, 12, 0], 199 | dtype=float) 200 | for m in out.index: 201 | i_m = Interest.mthly(m=m, i=interest.i) 202 | d_m = Interest.mthly(m=m, d=interest.d) 203 | out.loc[m] = [i_m, d_m, interest.i/i_m, interest.d/d_m, 204 | UDD.alpha(m=m, i=interest.i), 205 | UDD.beta(m=m, i=interest.i)] 206 | return out.round(5) 207 | 208 | 209 | if __name__ == "__main__": 210 | from actuarialmath.sult import SULT 211 | from actuarialmath.policyvalues import Contract 212 | 213 | print("SOA Question 7.9: (A) 38100") 214 | sult = SULT(udd=True) 215 | x, n, t = 45, 20, 10 216 | a = UDD(m=12, life=sult).temporary_annuity(x+10, t=n-10) 217 | print(a) 218 | A = UDD(m=0, life=sult).endowment_insurance(x+10, t=n-10) 219 | print(A) 220 | print(A*100000 - a*12*253) 221 | contract = Contract(premium=253*12, endowment=100000, benefit=100000) 222 | print(sult.gross_future_loss(A=A, a=a, contract=contract)) 223 | print() 224 | 225 | print("SOA Question 6.49: (C) 86") 226 | sult = SULT(udd=True) 227 | a = UDD(m=12, life=sult).temporary_annuity(40, t=20) 228 | A = sult.whole_life_insurance(40, discrete=False) 229 | P = sult.gross_premium(a=a, A=A, benefit=100000, initial_policy=200, 230 | renewal_premium=0.04, initial_premium=0.04) 231 | print(P/12) 232 | print() 233 | 234 | from actuarialmath.recursion import Recursion 235 | print("SOA Question 6.38: (B) 11.3") 236 | x, n = 0, 10 237 | life = Recursion().set_interest(i=0.05)\ 238 | .set_A(0.192, x=x, t=n, endowment=1, discrete=False)\ 239 | .set_E(0.172, x=x, t=n) 240 | a = life.temporary_annuity(x, t=n, discrete=False) 241 | print(a) 242 | 243 | def fun(a): # solve for discrete annuity, given continuous 244 | life = Recursion().set_interest(i=0.05)\ 245 | .set_a(a, x=x, t=n)\ 246 | .set_E(0.172, x=x, t=n) 247 | return UDD(m=0, life=life).temporary_annuity(x, t=n) 248 | a = life.solve(fun, target=a, grid=a) # discrete annuity 249 | P = life.gross_premium(a=a, A=0.192, benefit=1000) 250 | print(P) 251 | print() 252 | 253 | print("SOA Question 6.32: (C) 550") 254 | x = 0 255 | life = Recursion().set_interest(i=0.05).set_a(9.19, x=x) 256 | benefits = UDD(m=0, life=life).whole_life_insurance(x) 257 | payments = UDD(m=12, life=life).whole_life_annuity(x) 258 | print(benefits, payments) 259 | print(life.gross_premium(a=payments, A=benefits, benefit=100000)/12) 260 | print() 261 | 262 | print("SOA Question 6.22: (C) 102") 263 | life = SULT(udd=True) 264 | a = UDD(m=12, life=life).temporary_annuity(45, t=20) 265 | A = UDD(m=0, life=life).whole_life_insurance(45) 266 | print(life.gross_premium(A=A, a=a, benefit=100000)/12) 267 | print() 268 | 269 | print("Interest Functions at i=0.05") 270 | print("----------------------------") 271 | print(UDD.interest_frame(i=0.05)) 272 | print() 273 | 274 | 275 | -------------------------------------------------------------------------------- /src/actuarialmath/reserves.py: -------------------------------------------------------------------------------- 1 | """Reserves - Computes recursive, interim or modified reserves 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import pandas as pd 8 | import matplotlib.pyplot as plt 9 | from typing import Callable, Dict, Any 10 | from actuarialmath import PolicyValues, Contract 11 | 12 | class Reserves(PolicyValues): 13 | """Compute recursive, interim or modified reserves""" 14 | def __init__(self, **kwargs): 15 | super().__init__(**kwargs) 16 | self._reserves = {'V': {}} 17 | self.T = 0 18 | 19 | # 20 | # Set up reserves table for recursion 21 | # 22 | def set_reserves(self, T: int = 0, endowment: int | float= 0, 23 | V: Dict[int, float] | None = None) -> "Reserves": 24 | """Set values of the reserves table and the endowment benefit amount 25 | 26 | Args: 27 | T : max term of policy 28 | V : reserve values, keyed by time t 29 | endowment : endowment benefit amount 30 | 31 | Examples: 32 | >>> life = Reserves().set_reserves(T=3) 33 | """ 34 | if T: 35 | self.T = T 36 | if V: 37 | self._reserves['V'].update(V) 38 | self.T = max(len(V) - 1, self.T) 39 | self._reserves['V'][0] = 0 # initial reserve is 0 by equivalence 40 | self._reserves['V'][self.T] = endowment # n_V is 0 or endowment 41 | return self 42 | 43 | def fill_reserves(self, x: int, s: int = 0, reserve_benefit: bool = False, 44 | contract: Contract | None = None) -> "Reserves": 45 | """Iteratively fill in missing values in reserves table 46 | 47 | Args: 48 | x : age selected 49 | s : starting from s years after selection 50 | reserve_benefit : whether benefit includes value of reserves 51 | contract : policy contract terms and expenses 52 | """ 53 | contract = contract or Contract() 54 | for _ in range(2): 55 | for t in range(self.T + 1): 56 | if self._reserves['V'].get(t, None) is not None: 57 | continue 58 | if t == contract.T: 59 | v = self.t_V(x=x, s=s, t=t, premium=0, benefit=lambda t: 0, 60 | per_policy = -contract.endowment) 61 | elif t == 1: 62 | v = self.t_V(x=x, s=s, t=t, premium=contract.premium, 63 | benefit=lambda t: contract.benefit, 64 | reserve_benefit=reserve_benefit, 65 | per_premium=contract.initial_premium, 66 | per_policy=contract.initial_policy) 67 | elif t == 0: 68 | v = 0 69 | else: 70 | v = self.t_V(x=x, s=s, t=t, premium=contract.premium, 71 | benefit=lambda t: contract.benefit, 72 | reserve_benefit=reserve_benefit, 73 | per_premium=contract.renewal_premium, 74 | per_policy=contract.renewal_policy) 75 | if v is not None: 76 | self._reserves['V'][t] = v 77 | return self 78 | 79 | def V_plot(self, ax: Any = None, color: str = 'r', title: str = ''): 80 | """Plot values from reserves tables 81 | 82 | Args: 83 | title : title to display 84 | color : color to plot curve 85 | 86 | Examples: 87 | >>> life.V_plot(title=f"Reserves for term insurance") 88 | """ 89 | if ax is None: 90 | fig, ax = plt.subplots(1, 1) 91 | y = [self._reserves['V'].get(t, None) for t in range(self.T + 1)] 92 | ax.plot(list(range(self.T + 1)), y, '.', color=color) 93 | ax.set_title(title) 94 | ax.set_ylabel(f"$_tV$", color=color) 95 | ax.set_xlabel(f"t") 96 | 97 | def reserves_frame(self): 98 | """Returns reserves table as a DataFrame""" 99 | return pd.DataFrame(self._reserves)\ 100 | .rename_axis('t')\ 101 | .rename(columns={'V':'V_t'}) 102 | 103 | # 104 | # Reserves recursion 105 | # 106 | def t_V_backward(self, x: int, s: int = 0, t: int = 0, premium: float = 0, 107 | benefit: Callable = lambda t: 1, 108 | per_premium: float = 0, per_policy: float = 0, 109 | reserve_benefit: bool = False) -> float | None: 110 | """Backward recursion (with optional reserve benefit) 111 | 112 | Args: 113 | x : age selected 114 | s : starting s years after selection 115 | t : year of reserve to solve 116 | benefit : benefit amount at t+1 117 | premium : amount of premium paid just after t 118 | per_premium : expense per $ premium 119 | per_policy : expense per policy 120 | reserve_benefit : whether reserve value at t+1 included in benefit 121 | """ 122 | if t+1 not in self._reserves['V']: 123 | return None 124 | V = self._reserves['V'][t+1] 125 | b = benefit(t+1) + V * reserve_benefit # total death benefit 126 | if V == b: # special case if death benefit == forward reserve 127 | V = b 128 | else: 129 | if V: 130 | V *= self.p_x(x=x, s=s+t) 131 | if b: 132 | V += self.q_x(x=x, s=s+t) * b 133 | V = V * self.interest.v - (premium*(1 - per_premium) - per_policy) 134 | return V 135 | 136 | def t_V_forward(self, x: int, s: int = 0, t: int = 0, premium: float = 0, 137 | benefit: Callable = lambda t: 1, 138 | per_premium: float = 0, per_policy: float = 0, 139 | reserve_benefit: bool = False) -> float | None: 140 | """Forward recursion (with optional reserve benefit) 141 | 142 | Args: 143 | x : age selected 144 | s : starting s years after selection 145 | t : year of reserve to solve 146 | benefit : benefit amount at t 147 | premium : amount of premium paid just after t-1 148 | per_premium : expense per $ premium 149 | per_policy : expense per policy 150 | reserve_benefit : whether reserve value at t included in benefit 151 | """ 152 | if t-1 not in self._reserves['V']: 153 | return None 154 | V = self._reserves['V'][t-1] 155 | V = (V + premium*(1 - per_premium) - per_policy) / self.interest.v 156 | if benefit(t): 157 | V -= self.q_x(x=x, s=s+t-1) * benefit(t) 158 | if not reserve_benefit: 159 | V /= self.p_x(x=x, s=s+t-1) 160 | return V 161 | 162 | def t_V(self, x: int, s: int = 0, t: int = 0, 163 | premium: float = 0, benefit: Callable = lambda t: 1, 164 | reserve_benefit: bool = False, 165 | per_premium: float = 0, per_policy: float = 0) -> float | None: 166 | """Solve year-t reserves by forward or backward recursion 167 | 168 | Args: 169 | x : age selected 170 | s : starting s years after selection 171 | t : year of reserve to solve 172 | benefit : benefit amount 173 | premium : amount of premium 174 | per_premium : expense per $ premium 175 | per_policy : expense per policy 176 | reserve_benefit : whether reserve value included in benefit 177 | 178 | Examples: 179 | >>> G, x = 368.05, 0 180 | >>> def fun(P): # solve net premium from expense reserve equation 181 | >>> return life.t_V(x=x, t=2, premium=G-P, benefit=lambda t: 0, 182 | >>> per_policy=5+.08*G) 183 | >>> P = life.solve(fun, target=-23.64, grid=[.29, .31]) / 1000 184 | """ 185 | if t in self._reserves['V']: # already solved in reserves table 186 | return self._reserves['V'][t] 187 | V = self.t_V_forward(x=x, s=s, t=t, premium=premium, 188 | benefit=benefit, 189 | reserve_benefit=reserve_benefit, 190 | per_premium=per_premium, 191 | per_policy=per_policy) 192 | if V is not None: 193 | return V 194 | V = self.t_V_backward(x=x, s=s, t=t, premium=premium, 195 | benefit=benefit, 196 | reserve_benefit=reserve_benefit, 197 | per_premium=per_premium, 198 | per_policy=per_policy) 199 | if V is not None: 200 | return V 201 | 202 | # 203 | # Interim reserves 204 | # 205 | def r_V_backward(self, x: int, s: int = 0, r: float = 0, 206 | benefit: int = 1) -> float | None: 207 | """Backward recursion for interim reserves 208 | 209 | Args: 210 | x : age of selection 211 | s : years after selection 212 | r : solve for interim reserve at fractional year x+s+r 213 | benefit : benefit amount in year x+s+1 214 | """ 215 | s = int(s + r) 216 | r = r - math.floor(r) 217 | if s+1 not in self._reserves['V']: # forward recursion 218 | return None 219 | V = self._reserves['V'][s+1] 220 | if V: 221 | V *= self.p_r(x, s=s, r=r, t=1-r) 222 | if benefit: 223 | V += self.q_r(x, s=s, r=r, t=1-r) * benefit 224 | V = V * self.interest.v_t(1-r) 225 | return V 226 | 227 | def r_V_forward(self, x: int, s: int = 0, r: float = 0, 228 | premium: float = 0, benefit: int = 1) -> float | None: 229 | """Forward recursion for interim reserves 230 | 231 | Args: 232 | x : age of selection 233 | s : years after selection 234 | r : solve for interim reserve at fractional year x+s+r 235 | benefit : benefit amount in year x+s+1 236 | premium : premium amount just after year x+s 237 | """ 238 | s = int(s + r) 239 | r = r - math.floor(r) 240 | if s not in self._reserves['V']: 241 | return None 242 | V = self._reserves['V'][s] 243 | V = (V + premium) / self.interest.v_t(r) 244 | if benefit: 245 | V -= self.q_r(x, s=s, t=r) * benefit * self.interest.v_t(1-r) 246 | V /= self.p_r(x, s=s, t=r) 247 | return V 248 | 249 | # 250 | # Full Preliminary Term (FPT) modified reserves 251 | # 252 | def FPT_premium(self, x: int, s: int = 0, n: int = PolicyValues.WHOLE, 253 | b: int = 1, first: bool = False) -> float: 254 | """Initial or renewal Full Preliminary Term premiums 255 | 256 | Args: 257 | x : age of selection 258 | s : years after selection 259 | n : term of insurance 260 | b : benefit amount in year x+s+1 261 | first : calculate year 1 (True) or year 2+ (False) FPT premium 262 | """ 263 | if first: 264 | return self.net_premium(x, s=s, b=b, t=1) 265 | else: 266 | return self.net_premium(x, s=s+1, b=b, t=self.add_term(n, -1)) 267 | 268 | def FPT_policy_value(self, x: int, s: int = 0, t: int = 0, b: int = 1, 269 | n: int = PolicyValues.WHOLE, 270 | endowment: int = 0, discrete: bool = True) -> float: 271 | """Compute Full Preliminary Term policy value at time t 272 | 273 | Args: 274 | x : age of selection 275 | s : years after selection 276 | n : term of insurance 277 | t : year of policy value to calculate 278 | b : benefit amount in year x+s+1 279 | endowment : endowment amount 280 | discrete : fully discrete (True) or continuous (False) insurance 281 | """ 282 | if t in [0, 1]: # FPT is 0 at t = 0 or 1 283 | return 0 284 | else: 285 | return self.net_policy_value(x, s=s+1, t=t-1, n=self.add_term(n,-1), 286 | b=b, endowment=endowment, 287 | discrete=discrete) 288 | 289 | 290 | if __name__ == "__main__": 291 | from actuarialmath.sult import SULT 292 | from actuarialmath.policyvalues import Contract 293 | life = SULT() 294 | x, T, b = 50, 20, 500000 # $500K 20-year term insurance for (50) 295 | P = life.net_premium(x=x, t=T, b=b) 296 | life.set_reserves(T=T)\ 297 | .fill_reserves(x=x, contract=Contract(premium=P, benefit=b)) 298 | life.V_plot(title=f"Reserves for ${b} {T}-year term insurance issued to ({x})") 299 | 300 | if False: 301 | print("SOA Question 7.31: (E) 0.310") 302 | x = 0 303 | life = Reserves().set_reserves(T=3) 304 | print(life._reserves) 305 | G = 368.05 306 | def fun(P): # solve net premium from expense reserve equation 307 | return life.t_V(x=x, t=2, premium=G-P, benefit=lambda t: 0, 308 | per_policy=5+.08*G) 309 | P = life.solve(fun, target=-23.64, grid=[.29, .31]) / 1000 310 | print(P) 311 | print() 312 | 313 | from actuarialmath.sult import SULT 314 | print("SOA Question 7.13: (A) 180") 315 | life = SULT() 316 | V = life.FPT_policy_value(40, t=10, n=30, endowment=1000, b=1000) 317 | print(V) 318 | print() 319 | -------------------------------------------------------------------------------- /src/actuarialmath/mortalitylaws.py: -------------------------------------------------------------------------------- 1 | """Mortality Laws: Applies shortcut formulas for Beta, Uniform, Makeham and Gompertz 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | from actuarialmath import Reserves 7 | 8 | class MortalityLaws(Reserves): 9 | """Apply shortcut formulas for special mortality laws""" 10 | 11 | def __init__(self, **kwargs): 12 | assert 'udd' not in kwargs, "Fractional age must assume same mortality law" 13 | super().__init__(**kwargs) 14 | 15 | # 16 | # Fractional age actuarial survival functions can use continuous laws 17 | # 18 | def l_r(self, x: int, s: int = 0, r: float = 0.) -> float: 19 | """Fractional lives given special mortality law: l_[x]+s+r 20 | 21 | Args: 22 | x : age of selection 23 | s : years after selection 24 | r : fractional year after selection 25 | """ 26 | return self.l(x, s+r) 27 | 28 | def p_r(self, x: int, s: int = 0, r: float = 0., t: float = 1.) -> float: 29 | """Fractional age survival probability given special mortality law 30 | 31 | Args: 32 | x : age of selection 33 | s : years after selection 34 | r : fractional year after selection 35 | t : death within next t fractional years 36 | """ 37 | return self.S(x, s+r, t) 38 | 39 | def q_r(self, x: int, s: int = 0, r: float = 0., t: float = 1., 40 | u: float = 0.) -> float: 41 | """Fractional age deferred mortality given special mortality law 42 | 43 | Args: 44 | x : age of selection 45 | s : years after selection 46 | r : fractional year after selection 47 | u : survive u fractional years, then... 48 | t : death within next t fractional years 49 | """ 50 | return self.p_r(x, s=s, r=r, t=u) - self.p_r(x, s=s, r=r, t=t+u) 51 | 52 | def mu_r(self, x: int, s: int = 0, r: float = 0.) -> float: 53 | """Fractional age force of mortality given special mortality law 54 | 55 | Args: 56 | x : age of selection 57 | s : years after selection 58 | r : fractional year after selection 59 | """ 60 | return self.mu(x, s+r) 61 | 62 | def f_r(self, x: int, s: int = 0, r: float = 0., t: float = 0.0) -> float: 63 | """fractional age lifetime density given special mortality law 64 | 65 | Args: 66 | x : age of selection 67 | s : years after selection 68 | r : fractional year after selection 69 | t : mortality at fractional year t 70 | """ 71 | return self.f(x, s+r, t) 72 | 73 | def e_r(self, x: int, s: int = 0, r: float = 0., 74 | t: float = Reserves.WHOLE) -> float: 75 | """Fractional age future lifetime given special mortality law 76 | 77 | Args: 78 | x : age of selection 79 | s : years after selection 80 | r : fractional year after selection 81 | t : limited at t years 82 | """ 83 | if t < 0: 84 | t = self._MAXAGE - (x + s + r) 85 | return self.integrate(lambda t: self.S(x, s+r, t), 0., float(t)) 86 | 87 | class Beta(MortalityLaws): 88 | """Shortcuts with beta distribution of deaths (is Uniform when alpha = 1) 89 | 90 | Args: 91 | omega : maximum age 92 | alpha : alpha paramter of beta distribution 93 | radix : assumed starting number of lives for survival function 94 | 95 | Examples: 96 | >>> print(Beta(omega=60, alpha=1/3).mu_x(35) * 1000) 97 | """ 98 | 99 | def __init__(self, omega: int, alpha: float, 100 | radix: int = MortalityLaws._RADIX, **kwargs): 101 | """Two parameters: alpha and omega, with mu(x) = alpha/(omega-x)""" 102 | 103 | super().__init__(**kwargs) 104 | 105 | def _mu(x: int, s: float) -> float: 106 | return alpha / (omega - (x+s)) 107 | 108 | def _l(x: int, s: float) -> float: 109 | return radix * (omega - (x+s))**alpha 110 | 111 | def _S(x: int, s,t : float) -> float: 112 | return ((omega-(x+s+t))/(omega-(x+s)))**alpha 113 | 114 | def _f(x: int, s,t : float) -> float: 115 | return alpha / (omega - (x+s)) 116 | 117 | self.set_survival(mu=_mu, l=_l, S=_S, f=_f, minage=0, maxage=omega) 118 | self.omega_ = omega # store omega parameter 119 | self.alpha_ = alpha # store alpha parameter 120 | 121 | def e_r(self, x: int, s: int = 0, t: float = Reserves.WHOLE) -> float: 122 | """Expectation of future lifetime through fractional age: e_[x]+s:t 123 | 124 | Args: 125 | x : age of selection 126 | s : years after selection 127 | t : limited at t years 128 | """ 129 | e = (self.omega_ - (x+s)) / (self.alpha_ + 1) 130 | if t > 0 and self.max_term(x+s, t) > t: # temporary expectation 131 | return e - self.p_r(x, s=s, t=t) * self.e_r(x, s=s+t) 132 | return e # complete expectation 133 | 134 | def e_x(self, x: int, s: int = 0, n: int = MortalityLaws.WHOLE, 135 | curtate: bool = False, moment: int = 1) -> float: 136 | """Shortcut formula for complete expectation 137 | 138 | Args: 139 | x : age of selection 140 | s : years after selection 141 | n : death within next n fractional years 142 | t : limited at t years 143 | curtate : whether curtate (True) or complete (False) lifetime 144 | moment : whether to compute first (1) or second (2) moment 145 | """ 146 | if n == 0: 147 | return 0 148 | if not curtate: 149 | if moment == 1: 150 | return self.e_r(x, s=s, t=n) 151 | if moment == self.VARIANCE and n < 0: # shortcut for complete variance 152 | return ((self.omega_ - (x + s)) 153 | / ((self.alpha_ + 1)**2 * (self.alpha_ + 1))) 154 | return super().e_x(x=x, s=s, n=n, curtate=curtate, moment=moment) 155 | 156 | class Uniform(Beta): 157 | """Shortcuts with uniform distribution of deaths aka DeMoivre's Law 158 | 159 | Args: 160 | omega : maximum age 161 | 162 | Examples: 163 | >>> print(Uniform(95).e_x(30, t=40, curtate=False)) # 27.692 164 | """ 165 | 166 | def __init__(self, omega: int, **kwargs): 167 | """One parameter: omega = maxage, with mu(x) = 1/(omega - x)""" 168 | super().__init__(omega=omega, alpha=1, **kwargs) 169 | 170 | def e_x(self, x: int, s: int = 0, t: int = Beta.WHOLE, 171 | curtate: bool = False, moment: int = 1) -> float: 172 | """Shortcut for expected future lifetime 173 | 174 | Args: 175 | x : age of selection 176 | s : years after selection 177 | t : limited at t years 178 | curtate : whether curtate (True) or complete (False) lifetime 179 | moment : whether to compute first (1) or second (2) moment 180 | """ 181 | if moment in [1, self.VARIANCE] and not curtate: 182 | if t < 0: 183 | if moment == self.VARIANCE: 184 | return (self.omega_ - x)**2 / 12 # complete shortcut 185 | else: 186 | return (self.omega_ - x) / 2 187 | elif moment == 1: # temporary expectation shortcut 188 | # (Pr[die within n years] * n/2) plus (Pr[survive n years] * n) 189 | t = self.max_term(x+s, t) 190 | t_p_x = t / (self.omega_ - x) 191 | return t_p_x * t + (1 - t_p_x) * (t / 2) 192 | return super().e_x(x=x, s=s, t=t, curtate=curtate, moment=moment) 193 | 194 | 195 | def E_x(self, x: int, s: int = 0, t: int = Beta.WHOLE, 196 | moment: int = 1) -> float: 197 | """Shortcut for Pure Endowment 198 | 199 | Args: 200 | x : age of selection 201 | s : years after selection 202 | t : term of pure endowment 203 | endowment : amount of pure endowment 204 | moment : compute first or second moment 205 | """ 206 | assert moment > 0 207 | if t == 0: 208 | return 1. 209 | if t < 0: 210 | return 0. 211 | t = self.max_term(x+s, t) 212 | t_p_x = (self.omega_ - x - t) / (self.omega_ - x) 213 | if moment == self.VARIANCE: # Bernoulli shortcut for variance 214 | return self.interest.v_t(t)**2 * t_p_x * (1 - t_p_x) 215 | return (self.interest.v_t(t)**moment * (self.omega_ - x - t) 216 | / (self.omega_ - x)) 217 | 218 | def whole_life_insurance(self, x: int, s: int = 0, moment: int = 1, 219 | b: int = 1, discrete: bool = True) -> float: 220 | """Shortcut for whole life insurance 221 | 222 | Args: 223 | x : age of selection 224 | s : years after selection 225 | b : amount of benefit 226 | moment : compute first or second moment 227 | discrete : benefit paid year-end (True) or moment of death (False) 228 | """ 229 | 230 | if not discrete: 231 | if moment == Beta.VARIANCE: 232 | return (self.whole_life_insurance(x, s=s, b=b, moment=2, 233 | discrete=False) - 234 | self.whole_life_insurance(x, s=s, b=b, moment=1, 235 | discrete=False)**2) * b**2 236 | return self.term_insurance(x, s=s, t=self.omega_-(x+s), b=b, 237 | moment=moment, discrete=False) 238 | return super().whole_life_insurance(x, s=s, moment=moment, b=b, 239 | discrete=discrete) 240 | 241 | def term_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 242 | moment: int = 1, discrete: bool = False) -> float: 243 | """Shortcut for term insurance 244 | 245 | Args: 246 | x : age of selection 247 | s : years after selection 248 | t : term of insurance 249 | b : amount of benefit 250 | moment : compute first or second moment 251 | discrete : benefit paid year-end (True) or moment of death (False) 252 | """ 253 | if not discrete and moment in [1, Beta.VARIANCE]: 254 | if moment == Beta.VARIANCE: 255 | return (self.term_insurance(x, s=s, b=b, t=t, moment=2, 256 | discrete=False) - 257 | self.term_insurance(x, s=s, b=b, t=t, 258 | discrete=False)**2) * b**2 259 | t = self.max_term(x+s, t) 260 | return (b * (1 - self.interest.v_t(t)) 261 | / (self.interest.delta * (self.omega_ - (x+s)))) 262 | return super().term_insurance(x, s=s, moment=moment, b=b, 263 | discrete=discrete) 264 | 265 | class Makeham(MortalityLaws): 266 | """Includes element in force of mortality that does not depend on age 267 | 268 | Args: 269 | A, B, c : parameters of Makeham distribution 270 | 271 | Examples: 272 | >>> print(Makeham(A=0.00022, B=2.7e-6, c=1.124).mu_x(60) * 0.9803) # 0.00316 273 | """ 274 | 275 | def __init__(self, A: float, B: float, c: float, **kwargs): 276 | assert c > 1, "Makeham requires c > 1" 277 | assert B > 0, "Makeham requires B > 0" 278 | assert A >= -B, "Makeham requires A >= -B" 279 | super().__init__(**kwargs) 280 | self.A_ = A 281 | self.B_ = B 282 | self.c_ = c 283 | 284 | def _mu(x, s): 285 | return A + B * c**(x+s) 286 | 287 | def _S(x, s, t): 288 | return math.exp(-A*t - B*c**(x+s) * (c**t - 1)/math.log(c)) 289 | 290 | self.set_survival(mu=_mu, S=_S) 291 | 292 | class Gompertz(Makeham): 293 | """Is Makeham's Law with A = 0 294 | 295 | Args: 296 | B, c : parameters of Gompertz distribution 297 | 298 | Examples: 299 | >>> print(Gompertz(B=0.00027, c=1.1).f_x(50, t=10)) # 0.04839 300 | """ 301 | 302 | def __init__(self, B: float, c: float, **kwargs): 303 | """Gompertz's Law is Makeham's Law with A = 0""" 304 | super().__init__(A=0., B=B, c=c, **kwargs) 305 | 306 | if __name__ == "__main__": 307 | print('Beta') 308 | life = Beta(omega=100, alpha=0.5) 309 | print(life.q_x(25, t=1, u=10)) # 0.0072 310 | print(life.e_x(25)) # 50 311 | print(Beta(omega=60, alpha=1/3).mu_x(35) * 1000) 312 | print() 313 | 314 | print('Uniform') 315 | uniform = Uniform(80).set_interest(delta=0.04) 316 | print(uniform.whole_life_annuity(20, discrete=False)) # 15.53 317 | print(uniform.temporary_annuity(20, t=5, discrete=False)) # 4.35 318 | print(Uniform(161).p_x(70, t=1)) # 0.98901 319 | print(Uniform(95).e_x(30, t=40, curtate=False)) # 27.692 320 | print() 321 | 322 | uniform = Uniform(omega=80).set_interest(delta=0.04) 323 | print(uniform.E_x(20, t=5)) # .7505 324 | print(uniform.whole_life_insurance(20, discrete=False)) # .3789 325 | print(uniform.term_insurance(20, t=5, discrete=False)) # .0755 326 | print(uniform.endowment_insurance(20, t=5, discrete=False)) # .8260 327 | print(uniform.deferred_insurance(20, u=5, discrete=False)) # .3033 328 | print() 329 | 330 | print('Gompertz/Makeham') 331 | life = Gompertz(B=0.000005, c=1.10) 332 | p = life.p_x(80, t=10) # 869.4 333 | print(life.portfolio_percentile(N=1000, mean=p, variance=p*(1-p), prob=0.99)) 334 | print(Gompertz(B=0.00027, c=1.1).f_x(50, t=10)) # 0.04839 335 | 336 | life = Makeham(A=0.00022, B=2.7e-6, c=1.124) 337 | print(life.mu_x(60) * 0.9803) # 0.00316 338 | -------------------------------------------------------------------------------- /src/actuarialmath/lifetable.py: -------------------------------------------------------------------------------- 1 | """Life Tables - Loads and calculates life tables 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | import math 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | import pandas as pd 9 | from typing import Dict 10 | from actuarialmath import Reserves 11 | 12 | class LifeTable(Reserves): 13 | """Calculate life table, and iteratively fill in missing values 14 | 15 | Args: 16 | udd : assume UDD or constant force of mortality for fractional ages 17 | verbose : whether to echo update steps 18 | 19 | Notes: 20 | 4 types of columns can be loaded and calculated in the life table: 21 | 22 | - 'q' : probability (x) dies in one year 23 | - 'l' : number of lives aged x 24 | - 'd' : number of deaths of age x 25 | - 'p' : probability (x) survives at least one year 26 | """ 27 | 28 | def __init__(self, udd: bool = True, verbose: bool = False, **kwargs): 29 | super().__init__(udd=udd, **kwargs) 30 | self._verbose = verbose 31 | self._table = {'l':{}, 'd':{}, 'q':{}, 'p':{}} # columns in life table 32 | 33 | # Set basic survival functions by interpolating lifetable integer ages 34 | def _mu(x: int, s: float) -> float: 35 | u = math.floor(s) 36 | return self.mu_r(x, s=u, r=s-u) 37 | 38 | def _l(x: int, s: float) -> float: 39 | u = math.floor(s) 40 | return self.l_r(x, s=u, r=s-u) 41 | 42 | def _S(x: int, s, t: float) -> float: 43 | u = math.floor(t) # u+r_t_x = u_t_x * 44 | return self.p_x(x, s=s, t=u) * self.p_r(x, s=s+u, t=t-u) 45 | 46 | def _f(x: int, s, t: float) -> float: 47 | u = math.floor(t) # f_x(u+r) = u_p_x * f_u(r) 48 | return self.p_x(x, s=s, t=u) * self.f_r(x, s=s+u, t=t-u) 49 | 50 | self.set_survival(mu=_mu, S=_S, f=_f, l=_l, minage=-1, maxage=-1) 51 | 52 | 53 | def set_table(self, 54 | radix: int = Reserves._RADIX, 55 | minage: int = -1, 56 | maxage: int = -1, 57 | fill: bool = True, 58 | l: Dict[int, float] | None = None, 59 | d: Dict[int, float] | None = None, 60 | p: Dict[int, float] | None = None, 61 | q: Dict[int, float] | None = None) -> "LifeTable": 62 | """Update life table 63 | 64 | Args: 65 | l : lives at start of year x, or 66 | d : deaths in year x, or 67 | p : probabilities that (x) survives one year, or 68 | q : probabilities that (x) dies in one year 69 | fill : whether to automatically fill table cells (default is True) 70 | minage : minimum age in table 71 | maxage : maximum age in table 72 | radix : initial number of lives 73 | 74 | Examples: 75 | 76 | >>> life = LifeTable(udd=True).set_table(l={90: 1000, 93: 825}, 77 | >>> d={97: 72}, 78 | >>> p={96: .2}, 79 | >>> q={95: .4, 97: 1}) 80 | """ 81 | 82 | inputs = {k:v for k,v in {'l':l, 'd':d, 'q':q, 'p':p}.items() if v} 83 | 84 | # infer min and max ages from inputs 85 | if minage < 0: 86 | minage = min([min(v) for v in inputs.values()]) 87 | if self._MINAGE < 0 or minage < self._MINAGE: 88 | self._MINAGE = minage 89 | else: 90 | self._MINAGE = minage 91 | if maxage < 0: 92 | maxage = max([max(v) for v in inputs.values()]) 93 | if self._MAXAGE < 0: 94 | self._MAXAGE = maxage + 1 95 | if maxage > self._MAXAGE: 96 | self._MAXAGE = maxage 97 | else: 98 | self._MAXAGE = maxage 99 | 100 | # update table from inputs 101 | for label, col in inputs.items(): 102 | self._table[label].update(col) 103 | 104 | # derive and fill table values 105 | if fill: 106 | self.fill_table(radix=radix) 107 | return self 108 | 109 | def fill_table(self, radix: int) -> "LifeTable": 110 | """Iteratively fill in missing table cells (does not check consistency) 111 | 112 | Args: 113 | radix : initial number of lives 114 | """ 115 | 116 | def q_x(x: int) -> float | None: 117 | """Helper to try compute one-year mortality rate for (x): 1_q_x""" 118 | if x in self._table['q']: 119 | return self._table['q'][x] 120 | if x in self._table['p']: 121 | return 1 - self._table['p'][x] 122 | if x in self._table['d'] and x in self._table['l']: 123 | return self._table['d'][x] / self._table['l'][x] 124 | return None 125 | 126 | def p_x(x: int) -> float | None: 127 | """Helper to try compute one-year survival for (x): 1_q_x""" 128 | if x in self._table['p']: 129 | return self._table['p'][x] 130 | if x in self._table['q']: 131 | return 1 - self._table['q'][x] 132 | return None 133 | 134 | def l_x(x: int) -> float | None: 135 | """Helper to try compute number of lives aged x: l_x""" 136 | if x in self._table['l']: 137 | return self._table['l'][x] 138 | if x+1 in self._table['l'] and x in self._table['q']: 139 | return self._table['l'][x+1] / (1 - self._table['q'][x]) 140 | if x-1 in self._table['l'] and x-1 in self._table['q']: 141 | return self._table['l'][x-1] * (1 - self._table['q'][x-1]) 142 | if x in self._table['d'] and x in self._table['q']: 143 | return self._table['d'][x] / self._table['q'][x] 144 | return None 145 | 146 | def d_x(x: int) -> float | None: 147 | """Helper to try compute number of deaths in one year for (x): d_x""" 148 | if x in self._table['d']: 149 | return self._table['d'][x] 150 | if x+1 in self._table['l'] and x in self._table['l']: 151 | return self._table['l'][x] - self._table['l'][x+1] 152 | else: 153 | return None 154 | 155 | # Iterate a few times to impute life table values 156 | funs = {'l': l_x, 'd': d_x, 'q': q_x, 'p': p_x} 157 | updated = 0 158 | for loop in range(2): # loop second time if radix needed 159 | prev = updated - 1 160 | while updated != prev: # continue while changes 161 | prev = updated 162 | for col, fun in funs.items(): # loop columns 163 | for x in range(self._MINAGE, self._MAXAGE + 1): # loop ages 164 | if x not in self._table[col]: 165 | value = fun(x) 166 | if value is not None: # update value 167 | self._table[col][x] = round(value, 7) 168 | updated += 1 # increment counter of changes 169 | if self._verbose: 170 | print(f"{updated} {col}(x={x}) = {value}") 171 | if not self._table['l']: # assume starting number of lives if necc 172 | self._table['l'][self._MINAGE] = radix 173 | return self 174 | 175 | def mu_x(self, x: int, s: int = 0, t: int = 0) -> float: 176 | """Compute mu_x from p_x in life table 177 | 178 | Args: 179 | x : age of selection 180 | s : years after selection 181 | t : death within next t years 182 | """ 183 | return -math.log(max(0.00001, self.p_x(x, s=s+t, t=1))) 184 | 185 | def l_x(self, x: int, s: int = 0) -> float: 186 | """Lookup l_x from life table 187 | 188 | Args: 189 | x : age of selection 190 | s : years after selection 191 | """ 192 | if x+s in self._table['l']: 193 | return self._table['l'][x+s] 194 | else: 195 | return 0 196 | 197 | def d_x(self, x: int, s: int = 0, t: int = 1) -> float: 198 | """Compute deaths as lives at x_t divided by lives at x 199 | 200 | Args: 201 | x : age of selection 202 | s : years after selection 203 | t : death within next t years 204 | """ 205 | if x+s+t <= self._MAXAGE: 206 | return self.l_x(x, s=s) - self.l_x(x, s=s+t) 207 | else: 208 | return 0. 209 | 210 | def p_x(self, x: int, s: int = 0, t: int = 1) -> float: 211 | """t_p_x = lives beginning year x+t divided lives beginning year x 212 | 213 | Args: 214 | x : age of selection 215 | s : years after selection 216 | t : death within next t years 217 | """ 218 | denom = self.l_x(x, s=s) 219 | if denom and x+s+t <= self._MAXAGE: 220 | return self.l_x(x, s=s+t) / denom 221 | else: 222 | return 0. # in the long term, we are all dead 223 | 224 | def q_x(self, x: int, s: int = 0, t: int = 1, u: int = 0) -> float: 225 | """Deferred mortality: u|t_q_x = (l[x+u] - l[x+u+t]) / l[x] 226 | 227 | Args: 228 | x : age of selection 229 | s : years after selection 230 | u : survive u years, then... 231 | t : death within next t years 232 | """ 233 | denom = self.l_x(x, s=s) 234 | if denom and x+s+t <= self._MAXAGE: 235 | return self.d_x(x, s=s+u, t=t) / self.l_x(x, s=s) 236 | else: 237 | return 1 # the only certainty in life 238 | 239 | def e_x(self, x: int, s: int = 0, n: int = Reserves.WHOLE, 240 | curtate: bool = True, moment: int = 1) -> float: 241 | """Expected curtate lifetime from sum of lives in table 242 | 243 | Args: 244 | x : age of selection 245 | s : years after selection 246 | n : future lifetime limited at n years 247 | curtate : whether curtate (True) or complete (False) expectations 248 | moment : whether to compute first (1) or second (2) moment 249 | 250 | """ 251 | if moment == 1: 252 | # E[K_x] = sum([self.p(x, k+1) for k in range(n)]) 253 | n = min(self._MAXAGE - x, n) if n > 0 else self._MAXAGE 254 | # approximate complete by UDD between integer age recursion 255 | e = sum([(1 - curtate)*(self.l(x, s=s+t) - self.l(x, s=s+t+1))*0.5 256 | + self.l(x, s=s+t+1) for t in range(n)]) # s_p_x = l_x+s/l_x 257 | return e / self.l(x, s=0) 258 | else: 259 | return super().e_x(x=x, s=s, n=n, curtate=curtate, moment=moment) 260 | 261 | def E_x(self, x: int, s: int = 0, t: int = 1, moment: int = 1) -> float: 262 | """Pure Endowment from life table and interest rate 263 | 264 | Args: 265 | x : age of selection 266 | s : years after selection 267 | t : survives t years 268 | moment : return first (1) or second (2) moment or variance (-2) 269 | """ 270 | if t == 0: 271 | return 1. 272 | if t < 0: 273 | return 0. 274 | t = self.max_term(x+s, t) 275 | p = self.l_x(x, s=s+t) / self.l_x(x, s=s) 276 | if moment == self.VARIANCE: 277 | return self.interest.v_t(t)**moment * p * (1 - p) 278 | if moment == 1: 279 | return self.interest.v_t(t) * p 280 | # SULT shortcut: t_E_x(moment=2) = t_E_x(moment=1) * v**t 281 | return self.interest.v_t(t)**(moment-1) * self.E_x(x, s=s, t=t) 282 | 283 | def __getitem__(self, col: str) -> Dict[int, float]: 284 | """Returns a column of the life table 285 | 286 | Args: 287 | col : name of life table column to return 288 | """ 289 | assert col[0] in self._table, f"must be one of {list(self._table.keys())}" 290 | return self._table[col[0]] 291 | 292 | def frame(self) -> pd.DataFrame: 293 | """Return life table columns and values in a DataFrame""" 294 | return pd.DataFrame.from_dict(self._table).sort_index(axis=0) 295 | 296 | 297 | if __name__ == "__main__": 298 | print("SOA Question 6.53: (D) 720") 299 | x = 0 300 | life = LifeTable().set_interest(i=0.08)\ 301 | .set_table(q={x: 0.1, x+1: 0.1, x+2: 0.1}) 302 | A = life.term_insurance(x, t=3) 303 | G = life.gross_premium(a=1, A=A, benefit=2000, initial_premium=0.35) 304 | print(A, G) 305 | print(life.frame()) 306 | print() 307 | 308 | print("SOA Question 6.41: (B) 1417") 309 | x = 0 310 | life = LifeTable().set_interest(i=0.05)\ 311 | .set_table(q={x:.01, x+1:.02}) 312 | P = 1416.93 313 | a = 1 + life.E_x(x, t=1) * 1.01 314 | A = (life.deferred_insurance(x, u=0, t=1) 315 | + 1.01 * life.deferred_insurance(x, u=1, t=1)) 316 | print(a, A) 317 | P = 100000 * A / a 318 | print(P) 319 | print(life.frame()) 320 | print() 321 | 322 | 323 | print("SOA Question 3.11: (B) 0.03") 324 | life = LifeTable(udd=True).set_table(q={50//2: .02, 52//2: .04}) 325 | print(life.q_r(50//2, t=2.5/2)) 326 | print(life.frame()) 327 | print() 328 | 329 | 330 | print("SOA Question 3.5: (E) 106") 331 | l = {60 + x: l * 11111 for x,l in enumerate([9, 8, 7, 6, 5, 4, 3, 2])} 332 | a, b = (LifeTable(udd=udd).set_table(l=l).q_r(60, u=3.4, t=2.5) 333 | for udd in [True, False]) 334 | print(100000 * (a - b)) 335 | print() 336 | 337 | 338 | print("SOA Question 3.14: (C) 0.345") 339 | life = LifeTable(udd=True).set_table(l={90: 1000, 93: 825}, 340 | d={97: 72}, 341 | p={96: .2}, 342 | q={95: .4, 97: 1}) 343 | print(life.q_r(90, u=93-90, t=95.5-93)) 344 | print(life.frame()) 345 | print() 346 | -------------------------------------------------------------------------------- /src/actuarialmath/mthly.py: -------------------------------------------------------------------------------- 1 | """1/Mthly - Calculates m'thly-pay insurance and annuities 2 | 3 | MIT License. Copyright 2022-2023 Terence Lim 4 | """ 5 | from typing import Callable 6 | import math 7 | import pandas as pd 8 | from actuarialmath import Annuity 9 | from actuarialmath import Actuarial 10 | 11 | class Mthly(Actuarial): 12 | """Compute 1/M'thly insurance and annuities 13 | 14 | Args: 15 | m : number of payments per year 16 | life : original survival and life contingent functions 17 | """ 18 | _methods = ['v_m', 'p_m', 'q_m', 'Z_m', 'E_x', 'A_x', 19 | 'whole_life_insurance', 'term_insurance', 'deferred_insurance', 20 | 'endowment_insurance', 'immediate_annuity', 'insurance_twin', 21 | 'annuity_twin', 'annuity_variance', 'whole_life_annuity', 22 | 'temporary_annuity', 'deferred_annuity', 'immediate_annuity'] 23 | 24 | def __init__(self, m: int, life: Annuity): 25 | self.life = life 26 | self.m = max(0, m) 27 | 28 | def v_m(self, k: int) -> float: 29 | """Compute discount rate compounded over k m'thly periods 30 | 31 | Args: 32 | k : number of m'thly periods to compound 33 | """ 34 | return self.life.interest.v_t(k / self.m) 35 | 36 | def q_m(self, x: int, s_m: int = 0, t_m: int = 1, u_m: int = 0) -> float: 37 | """Compute deferred mortality over m'thly periods 38 | 39 | Args: 40 | x : year of selection 41 | s_m : number of m'thly periods after selection 42 | u_m : survive number of m'thly periods , then 43 | t_m : dies within number of m'thly periods 44 | """ 45 | sr = s_m / self.m 46 | s = math.floor(sr) 47 | r = sr - s 48 | q = self.life.q_r(x, s=s, r=r, t=t_m/self.m, u=u_m/self.m) 49 | return q 50 | 51 | def p_m(self, x: int, s_m: int = 0, t_m: int = 1) -> float: 52 | """Compute survival probability over m'thly periods 53 | 54 | Args: 55 | x : year of selection 56 | s_m : number of m'thly periods after selection 57 | t_m : survives number of m'thly periods 58 | """ 59 | sr = s_m / self.m 60 | s = math.floor(sr) 61 | r = sr - s 62 | return self.life.p_r(x, s=s, r=r, t=t_m/self.m) 63 | 64 | def Z_m(self, x: int, s: int = 0, t: int = 1, 65 | benefit: Callable = lambda x,t: 1, moment: int = 1): 66 | """Return PV of insurance r.v. Z and probability of death at mthly intervals 67 | 68 | Args: 69 | x : year of selection 70 | s : years after selection 71 | t : year of death 72 | benefit : amount of benefit by year and age selected 73 | moment : return first or second moment 74 | 75 | Returns: 76 | DataFrame, indexed by mthly period, with column names ['Z', 'p'] 77 | 78 | Examples: 79 | >>> life = LifeTable(udd=False).set_table(q={0:.16,1:.23})\ 80 | >>> .set_interest(i_m=.18,m=2) 81 | >>> mthly = Mthly(m=2, life=life) 82 | >>> Z = mthly.Z_m(0, t=2, benefit=lambda x,t: 300000 + t*30000*2) 83 | """ 84 | Z = [(benefit(x+s, k/self.m) * self.v_m(k+1))**moment 85 | for k in range(t * self.m)] 86 | q = [self.q_m(x, s_m=s*self.m, u_m=k) for k in range (t*self.m)] 87 | return pd.DataFrame.from_dict(dict(m=range(1, self.m*t + 1), Z=Z, q=q))\ 88 | .set_index('m') 89 | 90 | def E_x(self, x: int, s: int = 0, t: int = 1, moment: int = 1, 91 | endowment: int = 1) -> float: 92 | """Compute pure endowment factor 93 | 94 | Args: 95 | x : year of selection 96 | s : years after selection 97 | t : term length in years 98 | moment : return first or second moment 99 | endowment : endowment amount 100 | """ 101 | assert moment > 0 102 | return self.life.E_x(x, s=s, t=t, moment=moment) * endowment**moment 103 | 104 | def A_x(self, x: int, s: int = 0, t: int = 1, u: int = 0, 105 | benefit: Callable = lambda x,t: 1, moment: int = 1) -> float: 106 | """Compute insurance factor with m'thly benefits 107 | 108 | Args: 109 | x : year of selection 110 | s : years after selection 111 | u : years deferred 112 | t : term of insurance in years 113 | benefit : amount of benefit by year and age selected 114 | moment : return first or second moment 115 | """ 116 | assert moment in [1, 2] 117 | t = self.max_term(x+s, t) 118 | if self.m > 0: 119 | A = sum([(benefit(x+s, k/self.m) * self.v_m(k+1))**moment 120 | * self.q_m(x, s_m=s*self.m, u_m=k) 121 | for k in range((t+u) * self.m)]) 122 | else: 123 | Z = lambda t: ((benefit(x+s, t+u) * self.life.v_t(t+u))**moment 124 | * self.life.f(x, s, t+u)) 125 | A = self.life.integrate(Z, 0, t) 126 | return A 127 | 128 | def whole_life_insurance(self, x: int, s: int = 0, moment: int = 1, 129 | b: int = 1) -> float: 130 | """Whole life insurance: A_x 131 | 132 | Args: 133 | x : age of selection 134 | s : years after selection 135 | b : amount of benefit 136 | moment : compute first or second moment 137 | """ 138 | assert moment in [1, 2, Actuarial.VARIANCE] 139 | if moment == Actuarial.VARIANCE: 140 | A2 = self.whole_life_insurance(x, s=s, moment=2) 141 | A1 = self.whole_life_insurance(x, s=s) 142 | return self.life.insurance_variance(A2=A2, A1=A1, b=b) 143 | return sum(self.A_x(x, s=s, b=b, moment=moment)) 144 | 145 | 146 | def term_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 147 | moment: int = 1) -> float: 148 | """Term life insurance: A_x:t^1 149 | 150 | Args: 151 | x : year of selection 152 | s : years after selection 153 | t : term of insurance in years 154 | b : amount of benefit 155 | moment : return first or second moment 156 | """ 157 | assert moment in [1, 2, Actuarial.VARIANCE] 158 | if moment == Actuarial.VARIANCE: 159 | A2 = self.term_insurance(x, s=s, t=t, moment=2) 160 | A1 = self.term_insurance(x, s=s, t=t) 161 | return self.life.insurance_variance(A2=A2, A1=A1, b=b) 162 | A = self.whole_life_insurance(x, s=s, b=b, moment=moment) 163 | if t < 0 or self.life.max_term(x+s, t) < t: 164 | return A 165 | E = self.E_x(x, s=s, t=t, moment=moment) 166 | A -= E * self.whole_life_insurance(x, s=s+t, b=b, moment=moment) 167 | return A 168 | 169 | def deferred_insurance(self, x: int, s: int = 0, n: int = 0, b: int = 1, 170 | t: int = Annuity.WHOLE, moment: int = 1) -> float: 171 | """Deferred insurance n|_A_x:t^1 = discounted whole life 172 | 173 | Args: 174 | x : year of selection 175 | s : years after selection 176 | u : years to defer 177 | t : term of insurance in years 178 | b : amount of benefit 179 | moment : return first or second moment 180 | """ 181 | if self.life.max_term(x+s, n) < n: 182 | return 0. 183 | if moment == self.VARIANCE: 184 | A2 = self.deferred_insurance(x, s=s, t=t, n=n, moment=2) 185 | A1 = self.deferred_insurance(x, s=s, t=t, n=n) 186 | return self.life.insurance_variance(A2=A2, A1=A1, b=b) 187 | E = self.E_x(x, s=s, t=n, moment=moment) 188 | A = self.term_insurance(x, s=s+n, t=t, b=b, moment=moment) 189 | return E * A # discount insurance by moment*force of interest 190 | 191 | def endowment_insurance(self, x: int, s: int = 0, t: int = 1, b: int = 1, 192 | endowment: int = -1, moment: int = 1) -> float: 193 | """Endowment insurance: A_x:t = term insurance + pure endowment 194 | 195 | Args: 196 | x : year of selection 197 | s : years after selection 198 | t : term of insurance in years 199 | b : amount of benefit 200 | endowment : amount of endowment 201 | moment : return first or second moment 202 | """ 203 | if moment == self.VARIANCE: 204 | A2 = self.endowment_insurance(x, s=s, t=t, endowment=endowment, 205 | b=b, moment=2) 206 | A1 = self.endowment_insurance(x, s=s, t=t, endowment=endowment, 207 | b=b) 208 | return self.life.insurance_variance(A2=A2, A1=A1, b=b) 209 | E = self.E_x(x, s=s, t=t, moment=moment) 210 | A = self.term_insurance(x, s=s, t=t, b=b, moment=moment) 211 | return A + E * (b if endowment < 0 else endowment)**moment 212 | 213 | def insurance_twin(self, a: float) -> float: 214 | """Return insurance twin of m'thly annuity 215 | 216 | Args: 217 | a : twin annuity factor 218 | """ 219 | d = self.life.interest.d 220 | d_m = self.life.interest.mthly(m=self.m, d=d) 221 | return (1 - d_m * a) 222 | 223 | def annuity_twin(self, A: float) -> float: 224 | """Return value of annuity twin of m'thly insurance 225 | 226 | Args: 227 | A : amount of m'thly insurance 228 | 229 | Examples: 230 | >>> mthly = Mthly(m=12, life=Annuity().set_interest(i=0.06)) 231 | >>> mthly.annuity_twin(A=0.4075)*15*12 232 | """ 233 | d = self.life.interest.d 234 | d_m = self.life.interest.mthly(m=self.m, d=d) 235 | return (1-A) / d_m 236 | 237 | def annuity_variance(self, A2: float, A1: float, b: float = 1) -> float: 238 | """Variance of m'thly annuity from m'thly insurance moments 239 | 240 | Args: 241 | A2 : double force of interest of m'thly insurance 242 | A1 : first moment of m'thly insurance 243 | b : amount of benefit 244 | 245 | Examples: 246 | >>> mthly = Mthly(m=12, life=Annuity().set_interest(i=0.06)) 247 | >>> mthly.annuity_variance(A1=0.4075, A2=0.2105, b=15*12) 248 | """ 249 | num = self.life.insurance_variance(A2=A2, A1=A1, b=b) 250 | den = self.life.interest.mthly(m=self.m, d=self.life.interest.d) 251 | return num / den**2 252 | 253 | def whole_life_annuity(self, x: int, s: int = 0, b: int = 1, 254 | variance: bool = False) -> float: 255 | """Whole life m'thly annuity: a_x 256 | 257 | Args: 258 | x : year of selection 259 | s : years after selection 260 | b : amount of benefit 261 | variance : return first moment (False) or variance (True) 262 | """ 263 | if variance: # short cut for variance of whole life 264 | A1 = self.whole_life_insurance(x, s=s, moment=1) 265 | A2 = self.whole_life_insurance(x, s=s, moment=2) 266 | return self.annuity_variance(A2=A2, A1=A1, b=b) 267 | return b * (1 - self.whole_life_insurance(x, s=s)) / self.d 268 | 269 | def temporary_annuity(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 270 | b: int = 1, variance: bool = False) -> float: 271 | """Temporary m'thly life annuity: a_x:t 272 | 273 | Args: 274 | x : year of selection 275 | s : years after selection 276 | t : term of annuity in years 277 | b : amount of benefit 278 | variance : return first moment (False) or variance (True) 279 | """ 280 | if variance: # short cut for variance of temporary life annuity 281 | A1 = self.term_insurance(x, s=s, t=t) 282 | A2 = self.term_insurance(x, s=s, t=t, moment=2) 283 | return self.annuity_variance(A2=A2, A1=A1, b=b) 284 | 285 | # difference of whole life on (x) and deferred whole life on (x+t) 286 | a = self.whole_life_annuity(x, s=s, b=b) 287 | if t < 0 or self.max_term(x+s, t) < t: 288 | return a 289 | a_t = self.whole_life_annuity(x, s=s+t, b=b) 290 | return a - (a_t * self.E_x(x, s=s, t=t)) 291 | 292 | def deferred_annuity(self, x: int, s: int = 0, u: int = 0, 293 | t: int = Annuity.WHOLE, b: int = 1) -> float: 294 | """Deferred m'thly life annuity due n|t_a_x = n+t_a_x - n_a_x 295 | 296 | Args: 297 | x : year of selection 298 | s : years after selection 299 | u : years of deferral 300 | t : term of annuity in years 301 | b : amount of benefit 302 | """ 303 | if self.life.max_term(x+s, u) < u: 304 | return 0. 305 | return self.E_x(x, s=s, t=u)*self.temporary_annuity(x, s=s+u, t=t, b=b) 306 | 307 | def immediate_annuity(self, x: int, s: int = 0, t: int = Annuity.WHOLE, 308 | b: int = 1) -> float: 309 | """Immediate m'thly annuity 310 | 311 | Args: 312 | x : year of selection 313 | s : years after selection 314 | t : term of annuity in years 315 | b : amount of benefit 316 | """ 317 | a = self.temporary_annuity(x, s=s, t=t) 318 | if self.m > 0: 319 | return (a - ((1 - self.E_x(x, s=s, t=t)) / self.m)) * b 320 | else: 321 | return a 322 | 323 | if __name__ == "__main__": 324 | from actuarialmath.lifetable import LifeTable 325 | 326 | print("SOA Question 6.4: (E) 1893.9") 327 | mthly = Mthly(m=12, life=Annuity().set_interest(i=0.06)) 328 | A1, A2 = 0.4075, 0.2105 329 | mean = mthly.annuity_twin(A1)*15*12 330 | var = mthly.annuity_variance(A1=A1, A2=A2, b=15 * 12) 331 | S = Annuity.portfolio_percentile(mean=mean, variance=var, prob=.9, N=200) 332 | print(S / 200) 333 | print() 334 | 335 | print("SOA Question 4.2: (D) 0.18") 336 | life = LifeTable(udd=False).set_table(q={0: 0.16, 1: 0.23})\ 337 | .set_interest(i_m=.18, m=2) 338 | mthly = Mthly(m=2, life=life) 339 | Z = mthly.Z_m(0, t=2, benefit=lambda x,t: 300000 + t*30000*2) 340 | print(Z) 341 | print(Z[Z['Z'] >= 277000].iloc[:, -1].sum()) 342 | print() 343 | --------------------------------------------------------------------------------