Dream on Python

Posts About Python & Programming

Project: Mortgage Calculator

Mortgage Calculator: namedtuple, custom types, operator overloading (May 16)

Возьмём учебный пример: подсчет процентов и выплат по fixed-rate mortgage. Допустим цена дома $500,000, начальный взнос: 20%. Это можно выразить следующим кодом на Python:

home_price = USD * 500000
down_payment = home_price * 0.2
mortgage = home_price - down_payment

print(f'{home_price=}')
print(f'{down_payment=}')
print(f'{mortgage=}')

loan_to_value = mortgage / home_price

print(f'{loan_to_value=:.1%}')

Тут мы заодно посчитали LTV, который в данном примере составит: 80% (ну раз первый взнос: 20%).

Мы тут пока не определили USD, но можно догадаться, речь про $1, а значит USD * 500000 означает: $500,000. Недостающий кусок кода разберём чуть позже, а пока посмотрим на вывод данного кода.

Output:

home_price=$500,000.00
down_payment=$100,000.00
mortgage=$400,000.00

loan_to_value=80.0%

Магическим образом числа форматируются как цены, хотя сама команда print этого вроде не делает. Определим, что ссуда берётся под 5% годовых на 30 лет. Пересчитываем в проценты за один месяц. Также вводим понятие amortization term которое означает срок ссуды в количестве месяцев:

mortgage_rate = 0.05
monthly_rate = (1 + mortgage_rate) ** (1 / 12) - 1

print(f'{mortgage_rate=:.1%}')
print(f'{monthly_rate=:.3%}')

mortgage_term = 30
amortization_term = mortgage_term * 12

Output:

mortgage_rate=5.0%
monthly_rate=0.407%

Теперь считаем выплаты как по процентам, так и возврат по ссуде. Вычисления скрыты в функции, которую разберём чуть позже.

monthly_interest, monthly_principle = payment(mortgage, amortization_term, monthly_rate)
monthly_payment = monthly_interest + monthly_principle
print(f'{monthly_interest=}')
print(f'{monthly_principle=}')
print(f'{monthly_payment=}')

Output:

monthly_interest=$1,629.65
monthly_principle=$490.57
monthly_payment=$2,120.22

Опять магия: цены форматируются корректно!

Функция payment()

А вот и недостающая функция подсчёта выплат по ссуде:

def payment(balance: Dollar, term: int, rate: float) -> tuple[Dollar, Dollar]:
    interest = balance * rate
    principle = interest / ((1 + rate) ** term - 1)

    return interest, principle

Считает по известным формулам и возвращает пару (tuple): оплата процентов и возврат ссуды. В сумме получим (месячный) платёж. На самом деле, этой функции всё равно, речь про месяцы или года. Используются абстрактные: balance, term, rate! Упрощая, допустим баланс по ссуде $400К, годовой процент: 5%, ссуда на 30 лет, то функция payment($400,000, 30, 5%) посчитает:

interest = $400,000 * 5% = $20,000
principle = $20,000 / (1.05^30 - 1) = $6,020

Общий платёж (за год): $5,000 + $1,505 = $26,020.

В терминах целых годов, ответ будет не совсем верный. Более правильным будет сделать пересчёт по месяцам. Именно поэтому вы вызываем эту функцию как payment($400,000, 12*30, 0.407%). Заметим, что функция имеет аннотацию к типам и для balance указан тип Dollar, который мы пока нигде не определили.

Тип Dollar

Dollar ⏤ это класс, который наследует все поля и свойства другого типа collections.namedtuple. Можно и без наследования обойтись, но namedtuple является очень полезным типом, с которым следует познакомиться.

Тип namedtuple позволяет определить наименованные кортежи. Как раз Dollar ⏤ это кортеж из всего одной, но именованной компоненты: amount. Как и обычные tuple, namedtuple тоже является неизменяемым ⏤ ещё одно полезное свойство.

Можно конечно же использовать namedtuple напрямую, например так:

Dollar = namedtuple('Dollar', 'amount', defaults=(1,))

Тут мы определяем новый тип: неизменяемый именованный кортеж с полем amount (со значением по умолчанию: 1). Но этого недостаточно, если хотим производить арифметические операции с объектами типа Dollar.

Поэтому Dollar наследует поле amount и свойства immutability, но определяет свои операторы. Например:

from collections import namedtuple
class Dollar(namedtuple('Dollar', 'amount', defaults=(1,))):
    def __mul__(self, other: Union[int, float]) -> 'Dollar':
        return Dollar(self.amount * other)

    def __truediv__(self, other: Union[int, float, 'Dollar']) -> 'Dollar':
        return self.amount / other.amount \
        if type(other) == Dollar \
        else Dollar(self.amount / other)

    def __add__(self, other: 'Dollar') -> 'Dollar':
        return Dollar(self.amount + other.amount)

    def __sub__(self, other: 'Dollar') -> 'Dollar':
        return Dollar(self.amount - other.amount)

    def __str__(self) -> str:
        return f'${self.amount:,.2f}' \
        if self.amount >= 0 \
        else f'-${-self.amount:,.2f}'

    def __repr__(self) -> str:
        return str(self)

Теперь можно ввести недостающий доллар:

USD = Dollar(1)

Code: https://onlinegdb.com/EySDwsUIo


Backend на FastAPI: mortgage calculator (May 23)

В папке webapptest создадим проект. Там сохраним:

В первую очередь устанавливаем необходимые библиотеки. Скопируйте в терминал следующую команду:

venv/bin/pip install "fastapi[all]" "uvicorn[standard]"

Проверить, что там установилось можно командой:

venv/bin/pip list

main.py и запуск WEB-сервиса:

Создаём backend и регистрируем API. Декораторы помним?

import fastapi

app = fastapi.FastAPI()

@app.get("/")
def read_root():
    return 'loan calculator'

Декоратор @app.get("/") устанавливает удалённую точку вызова для функции read_root(), которая просто возвращает строку (ничего хитрого!).

Запускаем сервис локально, например, на порту 8000:

venv/bin/uvicorn main:app --reload --port 8000

Output:

INFO:     Will watch for changes in these directories: ['/Users/slava/PycharmProjects/webapptest']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [29735] using watchgod
INFO:     Started server process [29737]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Если всё хорошо, пусть бежит!

Теперь через webbrowser можно вызвать функцию read_root, набрав следующий URL:

http://127.0.0.1:8000/

В браузере должно появиться:

"loan calculator"

Мы только что запустили минимальный WEB service (backend)!

Loan Calculator

Уже знакомы с именованными кортежами? Объекты LoanPayments будут содержать информацию о выплатах за определённый срок. Например, за один месяц, год, весь период и т.д.:

import collections
LoanPayments = collections.namedtuple(
    'LoanPayments',
    [
        'interest',
        'principle',
        'paid',
        'balance_left',
        'payments_done',
    ],
)

Создать объект этого типа можно так:

loan_payments = LoanPayments(
    interest=1000,
    principle=2000,
    paid=1000+2000,
    balance_left=100000 - 3000,
    payments_done=1,
)

Основная функция, которая считает выплаты по кредиту на заданном интервале:

def payment(balance: float, term: int, rate: float, payments: int = 1) -> LoanPayments:

    print(f'payment({balance=}, {term=}, {rate=}: {payments=})')

    total_interest, total_principle = 0, 0
    for _ in range(payments):
        interest = balance * rate
        principle = interest / ((1 + rate) ** term - 1)

        total_interest += interest
        total_principle += principle

        balance -= principle
        term -= 1

    return LoanPayments(
        interest=round(total_interest, 2),
        principle=round(total_principle, 2),
        paid=round(total_interest+total_principle, 2),
        balance_left=round(balance, 2),
        payments_done=payments,
    )

Входные параметры для функции:

Добавляем API:

@app.get("/amortization_loan/")
def amortization_loan(balance: float, months_left: int, annual_rate: float, months_to_pay: int = 1):

    annual_rate *= 0.01
    rate = (1 + annual_rate) ** (1 / 12) - 1

    monthly = payment(balance, months_left, rate, 1)
    annual = payment(balance, months_left, rate, 12)
    total = payment(balance, months_left, rate, months_left)
    paid = payment(balance, months_left, rate, months_to_pay)

    return {
        'monthly': monthly._asdict(),
        'annual': annual._asdict(),
        'total': total._asdict(),
        'paid': paid._asdict(),
    }

Теперь в браузере можно указать такой URL:

http://127.0.0.1:8000/amortization_loan/?balance=100000&months_left=360&annual_rate=5&months_to_pay=120

Что означает: вызвать функцию amortization_loan с параметрами:

В браузере получим ответ в виде JSON:

{"monthly":{"interest":407.41,"principle":122.64,"paid":530.06,"balance_left":99877.36,"payments_done":1},"annual":{"interest":4855.52,"principle":1505.14,"paid":6360.66,"balance_left":98494.86,"payments_done":12},"total":{"interest":90819.87,"principle":100000.0,"paid":190819.87,"balance_left":0.0,"payments_done":360},"paid":{"interest":44675.09,"principle":18931.53,"paid":63606.62,"balance_left":81068.47,"payments_done":120}}

Новый API добавлен, сервис loan calculator работает!

Следующий шаг можно пропустить:

Форматирование JSON

JSON можно красиво отформатрировать с помощью вызова модуля: python3 -m json.tool. Запускаем в терминале следующую команду:

echo '{"monthly":{"interest":407.41,"principle":122.64,"paid":530.06,"balance_left":99877.36,"payments_done":1},"annual":{"interest":4855.52,"principle":1505.14,"paid":6360.66,"balance_left":98494.86,"payments_done":12},"total":{"interest":90819.87,"principle":100000.0,"paid":190819.87,"balance_left":0.0,"payments_done":360},"paid":{"interest":44675.09,"principle":18931.53,"paid":63606.62,"balance_left":81068.47,"payments_done":120}}' | python3 -m json.tool

Output:

{
    "monthly": {
        "interest": 407.41,
        "principle": 122.64,
        "paid": 530.06,
        "balance_left": 99877.36,
        "payments_done": 1
    },
    "annual": {
        "interest": 4855.52,
        "principle": 1505.14,
        "paid": 6360.66,
        "balance_left": 98494.86,
        "payments_done": 12
    },
    "total": {
        "interest": 90819.87,
        "principle": 100000.0,
        "paid": 190819.87,
        "balance_left": 0.0,
        "payments_done": 360
    },
    "paid": {
        "interest": 44675.09,
        "principle": 18931.53,
        "paid": 63606.62,
        "balance_left": 81068.47,
        "payments_done": 120
    }
}

Interactive API docs

FastAPI также генерирует точку вызова для интерактивного API. Пока бежит ваш сервис, наберите в браузере следующий URL:

http://127.0.0.1:8000/docs

Должна генерироваться страница для вызова двух функций: Root Read и Amortization Loan.

Кликаем на стрелочки и далее на “Try it out” и “Execute”!

Получили очень простой UI, через который можно взаимодействовать с сервисом

Full Code: https://onlinegdb.com/7FLyB8K7P

See https://fastapi.tiangolo.com/