Вступительное слово
Данная статья относится к "изобретению велосипедов", и не особо планировалась к публикации. Плюс она довольно специфическая, из-за того что копаемся в расчётах. Однако, всё-таки хочу ей поделиться, "велосипедист" в ней не я один и я точно не последний. Ещё одним стимулом выложить на всеобщее обозрение, это одна из многочисленных статей по поводу закредитованности населения: РБК. 02 апреля 2024. "Число россиян с кредитами достигло 50 млн" - тема явно имеет обширный охват.
Предыстория
Взял кредит в 2021 году. Перед этим у меня был рабочий опыт, когда под любой проект заводился, условно говоря, файлик Excel и велась сверка, что насчитали партнёры по проекту, а что насчитали мы. И потихонечку приходили к общему взаимопониманию. То же самое я проделал с кредитом. Потихонечку дописывал логику пока проходил различные сценарии. В конце-концов кредит был выплачен, и так оказалось, что расчёты по нему полностью совпали с моими расчётами вплоть до копейки.
И ещё одна причина, почему я стал вести расчёты параллельно - это довольно непрозрачная картина по Графику платежей. Т.е. если циферки и даты в графике есть и они даже как-то сходятся в конце, то вот начисление процентов для меня была загадкой вплоть до последней выплаты. Уточню своё недоумение, если с определением простого процента (на котором строятся наверно все потребительские, авто кредиты и ипотеки) и логикой его вычисления мне было понятно, то загвоздку вызывали технические стороны: округления, различные коэффициент и механика расчёта.
Обо всём по прядку.
Цель которую преследовал
Иметь подручный инструмент для сверки графика платежей:
Который бы был достаточно точным
В котором можно было вести историю по конкретному кредиту
-
Который бы мог рассчитывать будущий аннуитетный платёж, основываясь на:
Условиях кредитования
Платежах, которые уже имели место
При условии, что досрочное погашение приводит к уменьшению аннуитетного платежа
При этом всё лишнее опущено:
День выплаты не меняется со временем
Процент не меняется со временем
Комиссии не взимаются
И ещё много прочего (здесь приведён довольно богатый перечень всех возможных условий https://calcus.ru/kreditnyj-kalkulyator-s-dosrochnym-pogasheniem)
Сервисы, которые позволяют это делать
И, продолжая тему с велосипедами, подобные сервисы, удовлетворяющие Цели, действительно есть. Особенно акцентирую на требованиях "Платежи, которые уже имели место" имеются:
https://calcus.ru/kreditnyj-kalkulyator-s-dosrochnym-pogasheniem
https://calculoan.ru/ (в примерах не фигурирует, но ведёт себя примерно как calc.consultant.ru за исключением последнего платежа)
Однако, хочется указать, на неоднородность их расчётов при досрочном частичном погашении.
Пример 1. Первоначальные условия (без досрочного частичного погашения)
Условия |
Значение |
---|---|
Сумма кредита |
5 100 000 |
Дата выдачи |
10.10.2022 |
Срок кредита |
8 лет |
Ставка кредита |
8.2% |
Все 3 сервиса выдают идентичную картинку. Аннуитетный платёж при этом считается по формуле (о чём я ещё упомяну ниже - по формуле результат получается довольно не точным)
Сумма аннуитетного платежа за месяц = Сумма кредита × ( (Годовая ставка / 100 / 12) / (1 - (1 + (Годовая ставка / 100 / 12) ) - Количество месяцев ) )
Сумма процентов = Остаток задолженности по кредиту × (Количество дней, за которые начисляются проценты × Годовая ставка / 100 / количество дней в году) см Порядок расчёта consultant.ru
calcus.ru
calc.consultant.ru
fincult.info
Пример 2. С досрочным частичным погашением
Первоначальные условия остаются из Примера 1.
Досрочное погашение |
Значение |
---|---|
Сумма платежа |
484 000 |
Дата платежа |
13.11.2022 |
Порядок частичного погашения |
Уменьшение суммы платежа |
А при частичном погашении расчёты во всех сервисах отличаются (один сервис просто не пересчитывает аннуитетный платёж). К слову, когда использовал реальные данные по кредиту, то наблюдалась аналогичная картина, плюс у банка был свой вариант, и у меня - свой, близкий по расчётам к банковскому, отличие составило менее 1 рубля в расчётный месяц.
И я не зря делаю принтскрин именно последнего платежа. В него попадает накопленная ошибка расчёта аннуитетного платежа за весь (или оставшийся) период кредита. И по абсолютной разнице (по модулю разницы) последнего и остальных аннуитетных платежей можно судить о корректности расчёта самого аннуитетного платежа в целом.
calcus.ru
calc.consultant.ru
fincult.info
Небольшой промежуточный вывод
Тут я больше оправдываю своё потраченное время, однако сделаю акцент на том, что вроде бы довольно обыденная вещь как кредит с начислением простого процента с аннуитетным платежом имеет несколько технических реализаций, т.е. нет одного единого подхода. И тут я даже не касаюсь условий типа изменения процента во времени или комиссии и прочего, а просто сравниваю базовые критерии/параметры. Следовательно, банальность, но озвучу, если вдруг нужда заставила пользоваться подобными сервисами - желательно не брать первый попавшийся, а попробовать подобрать тот, который ближе всех подходит к реальному кредиту.
Насколько мне известно, "правильный" аннуитетный (у которых модуль разницы последнего и остальных аннуитетных платежей минимален) не рассчитывается по формулам, а именно подбирается.
График платежей
Ещё претензия у меня была к графику платежей, что он не достаточно подробный. И на самом деле в конце своих мытарств пришёл в выводу, что текущий вид, к сожалению, расширять до подробного вида особо нельзя - расширенная версия требует большое кол-во допущений и пояснений, что конечному пользователю доставит больше неприятностей. Но всё-таки попробуем, буду основываться на собственной реализации.
На примере
Рассмотрим конкретный кредит с 2-мя досрочными погашениями.
Условия |
Значение |
---|---|
Сумма кредита |
1 000 000 |
Дата выдачи |
20.11.2023 |
Срок кредита |
1 год |
Ставка кредита |
12.3% |
На момент формирования Графика платежей наш кредитор находится примерно в промежутке от 2024-03-30 до 2024-04-20. До 2024-03-30 все платежи прошли планово. С двумя досрочными погашениями.
id_payment |
Досрочное погашение |
Значение |
---|---|---|
2 |
Сумма платежа |
100 000 |
2 |
Дата платежа |
21.01.2024 |
2 |
Порядок частичного погашения |
Уменьшение суммы платежа |
6 |
Сумма платежа |
30 000 |
6 |
Дата платежа |
30.03.2024 |
6 |
Порядок частичного погашения |
Уменьшение суммы платежа |
Его график платежей в привычном виде, назовём credit, выглядит ...
id_payment |
date |
days |
credit_sum |
payment |
accrued_interest |
principal_debt |
interest_rate |
---|---|---|---|---|---|---|---|
0 |
2023-11-20 |
1 000 000,00 |
|||||
1 |
2023-12-20 |
30 |
921 129,48 |
88 980,11 |
10 109,59 |
78 870,52 |
12,30 |
2 |
2024-01-20 |
31 |
841 755,06 |
88 980,11 |
9 605,69 |
79 374,42 |
12,30 |
3 |
2024-01-21 |
1 |
742 037,94 |
100 000,00 |
282,88 |
99 717,12 |
12,30 |
4 |
2024-02-20 |
30 |
742 037,94 |
7 481,20 |
7 481,20 |
0,00 |
12,30 |
5 |
2024-03-20 |
29 |
662 568,77 |
86 701,00 |
7 231,83 |
79 469,17 |
12,30 |
6 |
2024-03-30 |
10 |
634 795,44 |
30 000,00 |
2 226,67 |
27 773,33 |
12,30 |
7 |
2024-04-20 |
21 |
582 574,43 |
56 701,00 |
4 479,99 |
52 221,01 |
12,30 |
8 |
2024-05-20 |
30 |
501 746,93 |
86 701,00 |
5 873,50 |
80 827,50 |
12,30 |
9 |
2024-06-20 |
31 |
420 273,15 |
86 701,00 |
5 227,22 |
81 473,78 |
12,30 |
10 |
2024-07-20 |
30 |
337 809,33 |
86 701,00 |
4 237,18 |
82 463,82 |
12,30 |
11 |
2024-08-20 |
31 |
254 627,64 |
86 701,00 |
3 519,31 |
83 181,69 |
12,30 |
12 |
2024-09-20 |
31 |
170 579,36 |
86 701,00 |
2 652,72 |
84 048,28 |
12,30 |
13 |
2024-10-20 |
30 |
85 598,14 |
86 701,00 |
1 719,78 |
84 981,22 |
12,30 |
14 |
2024-11-20 |
31 |
0,00 |
86 489,90 |
891,76 |
85 598,14 |
12,30 |
... и в расширенном, в котором отображены все необходимые для расчёта поля и дополнительные записи, назовём credit_event, выглядит:
id_date |
id_payment |
is_payment_date |
date_type_event |
date |
days |
days_year |
credit_sum |
payment |
accrued_interest |
principal_debt |
interest_rate |
---|---|---|---|---|---|---|---|---|---|---|---|
1 |
0 |
False |
creditStartDate |
2023-11-20 |
1 000 000,00 |
||||||
2 |
1 |
True |
realPayDate |
2023-12-20 |
30 |
365 |
921 129,48 |
88 980,11 |
10 109,59 |
78 870,52 |
12,3 |
3 |
2 |
False |
noCharge |
2023-12-31 |
11 |
365 |
0,00 |
0,00 |
3 414,49 |
-3 414,49 |
12,3 |
4 |
2 |
True |
realPayDate |
2024-01-20 |
20 |
366 |
841 755,06 |
88 980,11 |
6 191,20 |
82 788,91 |
12,3 |
5 |
3 |
False |
realPayDate |
2024-01-21 |
1 |
366 |
742 037,94 |
100 000,00 |
282,88 |
99 717,12 |
12,3 |
6 |
4 |
True |
realPayDate |
2024-02-20 |
30 |
366 |
742 037,94 |
7 481,20 |
7 481,20 |
0,00 |
12,3 |
7 |
5 |
True |
realPayDate |
2024-03-20 |
29 |
366 |
662 568,77 |
86 701,00 |
7 231,83 |
79 469,17 |
12,3 |
8 |
6 |
False |
realPayDate |
2024-03-30 |
10 |
366 |
634 795,44 |
30 000,00 |
2 226,67 |
27 773,33 |
12,3 |
9 |
7 |
True |
planPayTheRestDate |
2024-04-20 |
21 |
366 |
582 574,43 |
56 701,00 |
4 479,99 |
52 221,01 |
12,3 |
10 |
8 |
True |
planPayDate |
2024-05-20 |
30 |
366 |
501 746,93 |
86 701,00 |
5 873,50 |
80 827,50 |
12,3 |
11 |
9 |
True |
planPayDate |
2024-06-20 |
31 |
366 |
420 273,15 |
86 701,00 |
5 227,22 |
81 473,78 |
12,3 |
12 |
10 |
True |
planPayDate |
2024-07-20 |
30 |
366 |
337 809,33 |
86 701,00 |
4 237,18 |
82 463,82 |
12,3 |
13 |
11 |
True |
planPayDate |
2024-08-20 |
31 |
366 |
254 627,64 |
86 701,00 |
3 519,31 |
83 181,69 |
12,3 |
14 |
12 |
True |
planPayDate |
2024-09-20 |
31 |
366 |
170 579,36 |
86 701,00 |
2 652,72 |
84 048,28 |
12,3 |
15 |
13 |
True |
planPayDate |
2024-10-20 |
30 |
366 |
85 598,14 |
86 701,00 |
1 719,78 |
84 981,22 |
12,3 |
16 |
14 |
True |
planPayDate |
2024-11-20 |
31 |
366 |
0,00 |
86 489,90 |
891,76 |
85 598,14 |
12,3 |
Описание полей
Поле |
Описание |
---|---|
id_date |
id даты |
id_payment |
id платежа |
is_payment_date |
Является ли датой платежа |
date_type_event |
Типы событий |
date |
Дата |
days |
Сколько дней прошло с предыдущей даты |
days_year |
Дней в году |
credit_sum |
Оставшаяся сумма кредита на дату |
payment |
Сумма платежа на дату (= accrued_interest + principal_debt) |
accrued_interest |
Начисленные проценты по кредиту за обозначенный период |
principal_debt |
Тело долга |
interest_rate |
Процент по кредиту, действующий за обозначенный период |
Начнём с начала
На графике выделены поля которые известны по условиям кредита
Плюс отмечены поля, которые известны по реальным платежам (напоминаю, в данной ситуации кредитор как будто находится где-то в периоде между датами от 2024-03-30 до 2024-04-20).
days
days заполняется разницей между предыдущей датой и текущей датой.
days_year
days_year заполняется количеством дней в году на дату.
accrued_interest
accrued_interest заполняется умножением credit_sum (Оставшаяся сумма кредита на дату) на прошлый id_payment, умноженная на interest_rate (Процент по кредиту, действующий за обозначенный период) с поправкой на кол-во дней за период (лучше свериться с формулой на скриншоте - там более подробно).
И тут мы притронулись к не очевидному моменту, который как раз и ставит под сомнение вопрос о расширении графика платежей. В текущем примере реализации ссылка для 2х расчётов accrued_interest на запись credit_sum сохраняется, когда все остальные ссылки на прочие поля - сдвинулись.
Обращаю внимание, что запись date_type_event = 'noCharge' тут должна иметь место, потому что в последний день года происходит смена days_year с 365 на 366, на которую ссылается accrued_interest.
И есть ещё небольшой момент, который зависит от реализации, но который влияет на документацию/пояснения конечному потребителю. Для наглядности возьмём изменение процента со временем. Рассмотрим 2 реализации.
В I-ой реализации interest_rate находится напротив промежутка действия процентной ставки. Т.е. напротив 31 дня из 366, пока действовала ставка 15% (однако новая ставка в 15% стала действовать с 2024-03-20).
Во II-ой реализации interest_rate находится напротив даты 2024-03-20 с которой новая 15% ставка началась (но в расчётах участвует 31 день из 366).
Обе равнозначны, логичны по своему и имеют сходный результат, однако приходится столкнуться с вкусовщиной, ведущей к допущениям. Я буду придерживаться I-ого варианта.
credit_sum
credit_sum заполняется следующим образом берётся credit_sum за предыдущий период, вычитается payment за текущий период и прибавляется accrued_interest за текущий период.
Отдельно отмечу расчёт credit_sum для id_payment = 2, который содержит date_type_event = 'noCharge'.
principal_debt
principal_debt = payment - accrued_interest
Из-за своей простоты данное поле плодит аномалии - отрицательные значения для date_type_event = 'noCharge'. Опять же - этих аномалий можно избежать, и схлопнуть с иными полями со сменой логики.
date_type_event
date_type_event - это типы событий.
date_type_event |
Типы событий |
Выплата |
Начисление |
Пересчёт credit_sum |
---|---|---|---|---|
creditStartDate |
Дата начала кредита |
Нет |
Нет |
Есть |
realPayDate |
Реальная дата платежа |
Есть |
Есть |
Есть |
planPayTheRestDate |
Плановая дата платежа, после реальной даты платежа, которая НЕ совпала с плановой * |
Есть |
Есть |
Есть |
planPayDate |
Плановая дата платежа |
Есть |
Есть |
Есть |
noCharge |
Никаких начислений / плат нет |
Нет |
Есть |
Нет |
* такое событие может быть только одно. Т.е., например, заплатили 01.12, а плановая дата платежа 02.12 в таком случае 02.12 будет отмечена как 'planPayTheRestDate'. Т.е. в эту дату будут начислены проценты, либо остаток от плановой суммы платежа.
Особенно хотелось бы остановиться на date_type_event = 'planPayTheRestDate'. В моём случае с кредитом и в зафиксированном в текущей реализации, поле payment ведёт себя по разному в зависимости от предусловий. Если предшествующий payment у ближайшего предыдущего 'realPayDate' > аннуитетного платежа на тот момент, то payment для 'planPayTheRestDate' равен сумме начисленных процентов.
Иначе payment для planPayTheRestDate будет равен остатку, т.е. "аннуитетного платежа на тот момент" - (минус) payment у ближайшего предыдущего 'realPayDate'.
Такое поведение было установлено опытным путём. Не уверен, что оно встречается во всех кредитах.
Итого по Графику платежей
Без дополнительных полей погрузиться в детальный разбор формирования Графика платежей невозможно. Однако, при их добавлении приходится идти на компромиссы. В итоге, я показал выше как мне видится, но это не единственный вариант компоновки, скомпоновать поля можно и иначе.
Техническая реализация
Как Вы уже наверно поняли, у меня есть техническая реализация всего выше описанного. Считаю, что скрывать её не за чем и приведу её ниже. Понимаю, что формат Habr-а подразумевает одностраничные статьи, из-за этого всю логику собрал в один единственный файл, так что не обессудьте. Написана на Python с использованием стандартных библиотек (кроме jsonschema для валидации входящего json и ответа), со значительной частью логики на SQL.
Логика обработки кратко
Логику спрямил
Берёт условия по кредиту в формате json
Валидирует
-
Создаёт БД в оперативной памяти (2 таблицы, 1 view)
Таблицы для приёма входящих условий по кредиту + расчёты дополнительных полей
view - для вывода Графика плтежей в привычном виде
Заполянет временную таблицу tbl_date для внесения всех встречающихся дат (для все возможных date_type_event)
Отфильтровывает уникальные даты в таблицу tbl_credit_events
Вычисляет при необходимости (если кредит ещё не закрыт) аннуитетный платёж методом подбора
Заполняет прочие поля (по логике описанной выше)
Проверяет на корректность полученных расчётов
Валидирует ответ
-
Выводит результат
В формате таблиц
В формате json
credit_terms.json
Тут лежат условия кредита и платежи, имевшие место. Описание полей можно найти в credit_annuity_payment_schedule.py, def get_json_schema_input()
credit_terms.json
{
"credit": {
"sum": 1000000.0,
"yearly_percent": 12.3,
"years_span": 1,
"date_start": "2023-11-20"
},
"payments": [
{"date": "2023-12-20", "payment": 88980.11},
{"date": "2024-01-20", "payment": 88980.11},
{"date": "2024-01-21", "payment": 100000.0},
{"date": "2024-02-20", "payment": 7481.2},
{"date": "2024-03-20", "payment": 86701.0},
{"date": "2024-03-30", "payment": 30000.0},
{"date": "2024-04-20", "payment": 56701.0},
{"date": "2024-05-20", "payment": 86701.0},
{"date": "2024-06-20", "payment": 86701.0},
{"date": "2024-07-20", "payment": 86701.0},
{"date": "2024-08-20", "payment": 86701.0},
{"date": "2024-09-20", "payment": 86701.0},
{"date": "2024-10-20", "payment": 86701.0},
{"date": "2024-11-20", "payment": 86489.9}
]
}
credit_annuity_payment_schedule.py
credit_annuity_payment_schedule.py
# -*- coding: utf-8 -*-
import os.path
import jsonschema
import logging
import json
import sqlite3
import datetime
import copy
c_dict_global = { # Глобальные переменные
"paths": { # Пути к файлам
"json": r"..\credit_terms.json", # Где лежит собственно файл, который нужно рассчитать
"test_json": r"..\credit_terms.json" # Где лежит файл основы для тестовых сценариев
},
"b_test_mode": False, # включен режим прогона тестовых сценариев True / нормальная работа, расчёт графика платежей False
"b_return_table": True, # (для "b_test_mode": False) вернуть таблицу True / вернуть словарь False
"logging": {
# "level": logging.INFO, # Уровень логирования
"level": logging.DEBUG, # Уровень логирования
"b_show_tbls_in_debug": True # Показывать в логах содержимое таблиц после запросов на изменние данных для уровня логирования DEBUG
}
}
def main() -> None:
logging_basic_config(c_dict_global["logging"]["level"])
dict_json = step_01_get_json_form_file(c_dict_global["paths"]["json"])
b_success = False
if not bool(dict_json):
logging.error("Файл пуст: {}".format(c_dict_global["paths"]["json"]))
else:
try:
jsonschema.validate(instance=dict_json, schema=get_json_schema_input())
except jsonschema.exceptions.ValidationError as exp:
logging.error(exp)
else:
b_success = True
if b_success:
dict_credit_annuity_payment_schedule = credit_annuity_payment_schedule(dict_json, c_dict_global["logging"]["b_show_tbls_in_debug"])
if c_dict_global["b_return_table"]:
print("\ncredit")
step_99_print_table_from_dict(dict_credit_annuity_payment_schedule["credit"])
print("\ncredit_events")
step_99_print_table_from_dict(dict_credit_annuity_payment_schedule["credit_events"])
print("\ntests")
print(json.dumps(dict_credit_annuity_payment_schedule["tests"], indent=2, ensure_ascii=False))
else:
print(json.dumps(dict_credit_annuity_payment_schedule, indent=2, ensure_ascii=False))
def test() -> None:
"""
Прогон тестовых сценариев
:return: None
"""
logging_basic_config(c_dict_global["logging"]["level"])
b_all_test_ok = True # Пройдены ли все тесты
dict_json = step_01_get_json_form_file(c_dict_global["paths"]["test_json"])
b_success = False
if not bool(dict_json):
logging.error("Файл пуст: {}".format(c_dict_global["paths"]["test_json"]))
else:
try:
jsonschema.validate(instance=dict_json, schema=get_json_schema_input())
except jsonschema.exceptions.ValidationError as exp:
logging.error(exp)
b_all_test_ok = False
else:
b_success = True
if b_success:
list_dict_test = test_step_01_test_scenario_list()
for dict_test in list_dict_test:
dict_sample = copy.deepcopy(dict_json)
dict_sample["payments"] = test_step_02_remove_dates_in_list(dict_sample["payments"], dict_test["date_from"])
logging.debug("Тестовый сценарий: " + dict_test["description"])
print("\nТестовый сценарий: " + dict_test["description"])
dict_credit_annuity_payment_schedule = credit_annuity_payment_schedule(dict_sample, c_dict_global["logging"]["b_show_tbls_in_debug"])
print(json.dumps(dict_credit_annuity_payment_schedule["tests"], indent=2))
if not dict_credit_annuity_payment_schedule["tests"]["success"]:
b_all_test_ok = False
if b_all_test_ok:
print("Итого: Все тесты пройдены удачно")
else:
print("Итого: ERROR! Тесты НЕ пройдены!!!")
def credit_annuity_payment_schedule(dict_motgage: dict, b_show_tbls_in_debug: bool = False) -> dict[str, any]:
"""
Построение графика платежей с аннуитетным платежом
:param dict_motgage: Параметры кредита
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
conn = sqlite3.connect(":memory:")
conn.row_factory = dict_factory
cursor = conn.cursor()
step_02_create_tbls(cursor)
step_03_insert_payments_real_date_priority_1(cursor, b_show_tbls_in_debug, dict_motgage["payments"])
step_04_insert_payments_plan_date_priority_3(cursor, b_show_tbls_in_debug, dict_motgage["credit"]["date_start"],
dict_motgage["credit"]["years_span"])
step_05_insert_no_charge_date_type_event_priority_4(cursor, b_show_tbls_in_debug,
dict_motgage["credit"]["date_start"],
dict_motgage["credit"]["years_span"])
step_06_transfer_dates_to_tbl_credit_events(cursor, b_show_tbls_in_debug)
cursor.execute("DROP TABLE [tbl_date];") # После переноса в [tbl_credit_events] таблица [tbl_date] уже не нужна
if step_07_vaildate_real_and_plan_dates(cursor):
step_08_update_credit_sum_credit_start(cursor, b_show_tbls_in_debug, dict_motgage["credit"])
step_09_update_interest_rate(cursor, b_show_tbls_in_debug, dict_motgage["credit"])
step_10_update_tbl_credit_events_days_year(cursor, b_show_tbls_in_debug)
step_11_update_tbl_credit_events_days(cursor, b_show_tbls_in_debug)
step_12_update_no_charge(cursor, b_show_tbls_in_debug)
step_13_update_real_payments(cursor, b_show_tbls_in_debug, dict_motgage["payments"])
dict_payment = step_14_get_credit_sum_accrued_interest(cursor)
if bool(dict_payment):
dict_payment = step_15_calculate_credit_sum_accrued_interest(dict_payment)
step_16_update_credit_sum_accrued_interest(cursor, b_show_tbls_in_debug, dict_payment)
del dict_payment
dict_id_date_max = step_17_select_id_date_max(cursor)
flt_sum = step_18_select_last_credit_sum(cursor, dict_id_date_max)
int_months = step_19_select_months_number(cursor, dict_id_date_max)
dict_ratios = step_20_select_month_ratios(cursor, dict_id_date_max)
flt_percent = step_21_select_average_yearly_percent(cursor, dict_id_date_max)
dict_plan_payment = step_22_calculate_annuity_payment(flt_sum, flt_percent, dict_ratios, int_months)
del dict_id_date_max, flt_sum, flt_percent, dict_ratios, int_months
if step_23_to_update_plan_pay_the_rest_date(cursor):
flt_last_payment = step_24_select_last_payment(cursor)
if flt_last_payment >= dict_plan_payment["payment"]:
step_25_update_accrued_interest_and_credit_sum(cursor, b_show_tbls_in_debug)
dict_id_date_max = step_26_select_id_date_max(cursor)
flt_sum = step_18_select_last_credit_sum(cursor, dict_id_date_max)
int_months = step_19_select_months_number(cursor, dict_id_date_max)
dict_ratios = step_20_select_month_ratios(cursor, dict_id_date_max)
flt_percent = step_21_select_average_yearly_percent(cursor, dict_id_date_max)
dict_plan_payment = step_22_calculate_annuity_payment(flt_sum, flt_percent, dict_ratios, int_months)
del dict_id_date_max, flt_sum, flt_percent, dict_ratios, int_months
else:
step_27_update_payment_for_plan_the_rest_date(cursor, b_show_tbls_in_debug,
dict_plan_payment["payment"] - flt_last_payment)
step_28_update_payment(cursor, b_show_tbls_in_debug, dict_plan_payment)
dict_payment = step_14_get_credit_sum_accrued_interest(cursor)
if bool(dict_payment):
dict_payment = step_15_calculate_credit_sum_accrued_interest(dict_payment)
step_16_update_credit_sum_accrued_interest(cursor, b_show_tbls_in_debug, dict_payment)
del dict_payment
step_29_update_principal_debt(cursor, b_show_tbls_in_debug)
step_30_update_the_last_date(cursor, b_show_tbls_in_debug)
dict_out = dict()
dict_out["credit_events"] = step_31_select_tbl_credit_events(cursor)
dict_out["credit"] = step_32_annuity_payment_schedule(cursor)
dict_out["tests"] = {
"details": {
"annuity_payment": dict_plan_payment["message"]["sucess"],
"test_last_payment": step_33_test_last_payment(cursor),
"test_principal_debt": step_34_test_principal_debt(cursor),
"test_rows": step_35_test_rows(cursor),
"jsonschema_credit_events_is_valid": step_36_validate_output(dict_out["credit_events"]),
"jsonschema_credit_is_valid": step_36_validate_output(dict_out["credit"])
}
}
dict_out["tests"]["success"] = dict_out["tests"]["details"]["annuity_payment"] \
and dict_out["tests"]["details"]["test_last_payment"] \
and dict_out["tests"]["details"]["test_principal_debt"] \
and dict_out["tests"]["details"]["test_rows"] \
and dict_out["tests"]["details"]["jsonschema_credit_events_is_valid"] \
and dict_out["tests"]["details"]["jsonschema_credit_is_valid"]
else:
dict_out = {
"credit_events": [],
"credit": [],
"tests": {
"details": {
"annuity_payment": False,
"test_last_payment": False,
"test_principal_debt": False,
"test_rows": False,
"jsonschema_credit_events_is_valid": False,
"jsonschema_credit_is_valid": False
},
"success": False
}
}
cursor.close()
conn.close()
return dict_out
def step_01_get_json_form_file(str_file_path: str) -> dict:
"""
Получение содержимого json файла
:param str_file_path: Путь к файлу
:return: Содежимое файла
"""
dict_credit_params = dict()
if not os.path.exists(str_file_path):
logging.error("Файл отсутствует: " + str_file_path)
else:
with open(str_file_path, "r", encoding="utf-8") as obj_file:
str_json_in = obj_file.read()
try:
dict_credit_params = json.loads(str_json_in)
except json.JSONDecodeError as exc:
logging.error("Ошибка парсинга json:\n" + str(exc))
else:
logging.debug("Парсинг json успешный: " + str(str_file_path))
return dict_credit_params
def step_02_create_tbls(cursor: sqlite3.Cursor) -> None:
"""
Создание таблиц для расчётов
:param cursor: sqlite3.Cursor
:return: None
"""
str_query = """
CREATE TABLE [tbl_date] ( -- промежуточная/временная таблица для внесения всех встречающихся дат
[date] TEXT(10) NOT NULL -- дата события
, [date_type_event] TEXT(16) NOT NULL CHECK([date_type_event] IN ( -- Типы событий
'creditStartDate' -- дата начала кредита; [priority] = 1
, 'realPayDate' -- реальная дата платежа; [priority] = 1
, 'planPayDate' -- плановая дата платежа; [priority] = 3
, 'noCharge' -- никаких начислений / плат нет; [priority] = 4
))
, [priority] INTEGER NOT NULL CHECK([priority] IN (1, 3, 4)) -- Приоритет типа даты,
-- если есть несколько вариантов для одной и той же даты; чем меньше - тем лучше
);
CREATE TABLE [tbl_credit_events] (
[id_date] INTEGER PRIMARY KEY AUTOINCREMENT -- id даты
, [id_payment] INTEGER NOT NULL CHECK([id_payment] >= 0) -- id платежа
, [is_payment_date] INTEGER NOT NULL CHECK([is_payment_date] IN (0, 1)) -- Является ли датой платежа
, [date_type_event] TEXT(16) NOT NULL CHECK([date_type_event] IN ( -- Типы событий
'creditStartDate' -- дата начала кредита; [priority] = 1
, 'realPayDate' -- реальная дата платежа; [priority] = 1
, 'planPayTheRestDate' -- плановая дата платежа, после реальной даты платежа, которая НЕ совпала с плановой
-- такое событие может быть только одно. Т.е., например, заплатили 01.12, а плановая дата платежа 02.12
-- в таком случае 02.12 будет отмечена как planPayTheRestDate. Т.е. в эту дату будут начислены проценты
-- , либо остаток от плановой суммы платежа;
-- [priority] = 2
, 'planPayDate' -- плановая дата платежа; [priority] = 3
, 'noCharge' -- никаких начислений / плат нет; [priority] = 4
))
, [date] TEXT(10) NOT NULL -- Дата в формате ISO 8601 (YYYY-MM-DD)
, [days] REAL CHECK([days] > 0) -- Сколько дней прошло с предыдущей даты
, [days_year] REAL CHECK([days_year] IN (365, 366)) -- Дней в году
, [credit_sum] REAL -- Оставшаяся сумма кредита на дату
, [payment] REAL -- Сумма платежа на дату
, [accrued_interest] REAL -- Начисленные проценты по кредиту за обозначенный период
, [principal_debt] REAL -- Тело долга
, [interest_rate] REAL CHECK([interest_rate] > 0) -- Процент по кредиту, действующий за обозначенный период
);
-- График погашения кредита в привычном виде
CREATE VIEW [v_credit] AS
WITH [tbl_01] AS (
SELECT [id_payment], [date]
FROM [tbl_credit_events]
WHERE [date_type_event] != 'noCharge'
)
, [tbl_02] AS (
SELECT [id_payment]
, MAX([credit_sum]) AS [credit_sum]
, SUM([days]) AS [days]
, ROUND(SUM([payment]), 2) AS [payment]
, ROUND(SUM([accrued_interest]), 2) AS [accrued_interest]
, ROUND(SUM([principal_debt]), 2) AS [principal_debt]
, MAX([interest_rate]) AS [interest_rate]
FROM [tbl_credit_events]
GROUP BY [id_payment]
)
SELECT [tbl_01].[id_payment]
, [tbl_01].[date]
, [tbl_02].[days]
, [tbl_02].[credit_sum]
, [tbl_02].[payment]
, [tbl_02].[accrued_interest]
, [tbl_02].[principal_debt]
, [tbl_02].[interest_rate]
FROM [tbl_01]
INNER JOIN [tbl_02]
ON [tbl_01].[id_payment] = [tbl_02].[id_payment];
"""
cursor.executescript(str_query)
def step_03_insert_payments_real_date_priority_1(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
list_payments: list[dict]) -> None:
"""
Заполнение таблицы [tbl_date] датами, [priority] = 1
realPayDate - реальные даты платежей
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param list_payments:
:return:
"""
str_query = """
INSERT INTO [tbl_date] ([date], [date_type_event], [priority])
VALUES (:date, 'realPayDate', 1);
"""
cursor.executemany(str_query, list_payments)
if b_show_tbls_in_debug:
logging.debug("[tbl_date]\n" + step_99_select_all_from_tbl(cursor, "tbl_date"))
def step_04_insert_payments_plan_date_priority_3(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
date_start: str, years_span: int) -> None:
"""
Заполнение таблицы [tbl_date] датами, [priority] = 1 и 3
creditStartDate - дата начала кредита
и planPayDate - планируемые даты платежей
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param date_start:
:param years_span:
:return:
"""
dict_preliminary_setting = {
"date_start": date_start,
"months_span": 12 * years_span + 1
}
str_query = """
WITH [tbl_01] ([date], [date_type_event], [priority]) AS (
VALUES
(:date_start, 'creditStartDate', 1)
UNION ALL
SELECT
date([date], '+1 months')
, 'planPayDate', 3
FROM [tbl_01]
LIMIT :months_span
)
INSERT INTO [tbl_date] ([date], [date_type_event], [priority])
SELECT [date], [date_type_event], [priority]
FROM [tbl_01];
"""
cursor.execute(str_query, dict_preliminary_setting)
if b_show_tbls_in_debug:
logging.debug("[tbl_date]\n" + step_99_select_all_from_tbl(cursor, "tbl_date"))
def step_05_insert_no_charge_date_type_event_priority_4(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
date_start: str, years_span: int) -> None:
"""
Заполнение таблицы [tbl_date] датами, [priority] = 4
noCharge - дата без оплаты, например конца года, для перехода с 365 на 366 дней в году
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param date_start:
:param years_span:
:return:
"""
dict_insert = {
"date_start": date_start,
"years_span": years_span,
"date_type_event": "noCharge",
"priority": 4
}
str_query = """
WITH [tbl_01] ([date_start]) AS (
SELECT strftime('%Y', :date_start) || '-01-01'
UNION ALL
SELECT date([date_start], '+1 year')
FROM [tbl_01]
LIMIT :years_span
)
, [tbl_02] ([date_start], [date_end]) AS (
SELECT [date_start], DATE([date_start], '+1 year', '-1 day')
FROM [tbl_01]
)
INSERT INTO [tbl_date] ([date], [date_type_event], [priority])
SELECT [date_end], :date_type_event, :priority
FROM [tbl_02];
"""
cursor.execute(str_query, dict_insert)
if b_show_tbls_in_debug:
logging.debug("[tbl_date]\n" + step_99_select_all_from_tbl(cursor, "tbl_date"))
def step_06_transfer_dates_to_tbl_credit_events(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Фильтрация основных дат из таблицы [tbl_date] и перенос их в [tbl_credit_events]
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
WITH [tbl_01_01] AS (
SELECT [date], MIN([priority]) AS [priority]
FROM [tbl_date]
GROUP BY [date]
)
, [tbl_01_02] AS (
SELECT [date]
FROM [tbl_date]
WHERE [date_type_event] = 'planPayDate'
)
, [tbl_01_03] AS (
-- удаляем повторные даты
-- имеющиеся date_type_event: realPayDate, planPayDate, noCharge
SELECT
ROW_NUMBER() OVER (ORDER BY [t1].[date]) AS [id_date]
, [t1].[date]
, CASE
WHEN [tbl_01_02].[date] IS NULL THEN 0
ELSE 1
END AS [is_payment_date]
, [t1].[date_type_event]
FROM [tbl_date] AS [t1]
INNER JOIN [tbl_01_01]
ON [t1].[date] = [tbl_01_01].[date]
AND [t1].[priority] = [tbl_01_01].[priority]
LEFT JOIN [tbl_01_02] ON [t1].[date] = [tbl_01_02].[date]
)
, [tbl_02_01] AS (
SELECT
ROW_NUMBER() OVER (ORDER BY [date]) AS [id_payment]
, *
FROM [tbl_01_03]
WHERE [date_type_event] IN ('creditStartDate', 'realPayDate', 'planPayDate')
)
, [tbl_02_02] AS ( -- опредяеляем date_type_event == 'planPayDate', который нужно переделать в 'planPayTheRestDate' (priority = 2)
SELECT [T2].*
FROM [tbl_02_01] AS [T1]
INNER JOIN [tbl_02_01] AS [T2]
ON [T1].[id_payment] + 1 = [T2].[id_payment]
AND [T1].[is_payment_date] = 0
AND [T1].[date_type_event] = 'realPayDate'
AND [T2].[date_type_event] = 'planPayDate'
)
, [tbl_02_03] AS (
-- собираем все типы дат
-- имеющиеся date_type_event: realPayDate, planPayDate, planPayTheRestDate, noCharge
SELECT [tbl_01_03].[id_date]
, [tbl_02_01].[id_payment]
, [tbl_01_03].[date], [tbl_01_03].[is_payment_date], [tbl_01_03].[date_type_event]
FROM [tbl_01_03]
LEFT JOIN [tbl_02_01]
ON [tbl_01_03].[date] = [tbl_02_01].[date]
WHERE [tbl_01_03].[id_date] NOT IN (
SELECT [id_date]
FROM [tbl_02_02]
)
UNION ALL
SELECT [id_date], [id_payment], [date], [is_payment_date], 'planPayTheRestDate'
FROM [tbl_02_02]
)
, [tbl_03_01] AS (
SELECT [T1].[id_payment]
, [T1].[id_date] AS [id_date_from]
, [T2].[id_date] AS [id_date_to]
FROM [tbl_02_03] AS [T1]
INNER JOIN [tbl_02_03] AS [T2]
ON [T1].[id_payment] + 1 = [T2].[id_payment]
)
, [tbl_03_02] AS (
SELECT
[tbl_02_03].[id_date]
, [tbl_02_03].[date]
, CASE
WHEN [tbl_03_01].[id_payment] IS NOT NULL THEN [tbl_03_01].[id_payment]
ELSE 0
END AS [id_payment]
, [tbl_02_03].[is_payment_date]
, [tbl_02_03].[date_type_event]
FROM [tbl_02_03]
LEFT JOIN [tbl_03_01]
ON [tbl_02_03].[id_date] > [tbl_03_01].[id_date_from]
AND [tbl_02_03].[id_date] <= [tbl_03_01].[id_date_to]
)
INSERT INTO [tbl_credit_events] ([date], [id_payment], [is_payment_date], [date_type_event])
SELECT [tbl_03_02].[date]
, [tbl_03_02].[id_payment]
, [tbl_03_02].[is_payment_date]
, [tbl_03_02].[date_type_event]
FROM [tbl_03_02]
ORDER BY [tbl_03_02].[id_date];
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_07_vaildate_real_and_plan_dates(cursor: sqlite3.Cursor) -> bool:
"""
Валидация дат. Нельзя пропускать не оплаченные 'planPayDate', т.е. нельзя вот такую последовательность иметь для
[date_type_event]: 'realPayDate', 'planPayDate', 'realPayDate'
:param cursor: sqlite3.Cursor
:return: true - валидный / false - не валидный
"""
str_query = """
WITH [tbl_01_plan_pay_date] AS (
SELECT MIN([date]) AS [min_plan_pay_date]
FROM [tbl_credit_events]
WHERE [date_type_event] IN (
'planPayDate'
, 'planPayTheRestDate'
)
)
, [tbl_02_real_pay_date] AS (
SELECT MAX([date]) AS [max_real_pay_date]
FROM [tbl_credit_events]
WHERE [date_type_event] = 'realPayDate'
)
SELECT
CASE
WHEN [tbl_02_real_pay_date].[max_real_pay_date] IS NULL THEN 1
WHEN [tbl_01_plan_pay_date].[min_plan_pay_date] IS NULL THEN 1
WHEN [tbl_01_plan_pay_date].[min_plan_pay_date] > [tbl_02_real_pay_date].[max_real_pay_date] THEN 1
ELSE 0
END AS [is_valid]
, [tbl_01_plan_pay_date].[min_plan_pay_date]
, [tbl_02_real_pay_date].[max_real_pay_date]
FROM [tbl_01_plan_pay_date], [tbl_02_real_pay_date];
"""
cursor.execute(str_query)
dict_validate = cursor.fetchone()
if dict_validate["is_valid"] == 1:
logging.debug("dates are valid")
return True
else:
logging.error("not valid: MIN([date]) for 'planPayDate' is '{}'\n".format(dict_validate["min_plan_pay_date"]) +
"and is greater then MAX([date]) for 'realPayDate' is {}. ".format(dict_validate["max_real_pay_date"]) +
"This is prohibited")
return False
def step_08_update_credit_sum_credit_start(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool, dict_motgage_credit: dict) -> None:
"""
Заполнение [credit_sum] для начала кредита
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param dict_motgage_credit: Условия кредита
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [credit_sum] = :sum
WHERE [id_date] = 1 -- лишнее, но для понимания лучше оставить
AND [date_type_event] = 'creditStartDate' -- лишнее, но для понимания лучше оставить
AND [date] = :date_start;
"""
cursor.execute(str_query, dict_motgage_credit)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_09_update_interest_rate(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool, dict_motgage_credit: dict) -> None:
"""
Обновление [interest_rate] для всего периода кредита
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param dict_motgage_credit: Условия кредита
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [interest_rate] = :yearly_percent
WHERE [id_date] != 1
AND [date_type_event] != 'creditStartDate';
"""
cursor.execute(str_query, dict_motgage_credit)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_10_update_tbl_credit_events_days_year(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Заполнение количества дней в году для всего периода кредита
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [days_year] =
JULIANDAY(DATE([date], 'start of year', '+1 year'))
- JULIANDAY(DATE([date], 'start of year'))
WHERE [id_date] != 1;
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_11_update_tbl_credit_events_days(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Заполнение количества дней прошедшего от предыдущей даты
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
WITH [tbl_1] ([id_date], [days]) AS (
SELECT [T2].[id_date]
, JULIANDAY([T2].[date]) - JULIANDAY([T1].[date]) AS [days]
FROM [tbl_credit_events] AS [T1]
INNER JOIN [tbl_credit_events] AS [T2]
ON [T1].[id_date] + 1 = [T2].[id_date]
)
UPDATE [tbl_credit_events] AS [T1]
SET [days] = [T2].[days]
FROM [tbl_1] AS [T2]
WHERE [T1].[id_date] = [T2].[id_date];
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_12_update_no_charge(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Обновление для [date_type_event] = 'noCharge' нулёвые [payment], [credit_sum]
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [payment] = 0
, [credit_sum] = 0
WHERE [date_type_event] = 'noCharge';
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_13_update_real_payments(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
dict_payments: dict[str, any]) -> None:
"""
Обновление реальных оплат
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param dict_payments:
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [payment] = :payment
WHERE [date_type_event] = 'realPayDate' -- лишнее, но для понимания лучше оставить
AND [date] = :date;
"""
cursor.executemany(str_query, dict_payments)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_14_get_credit_sum_accrued_interest(cursor: sqlite3.Cursor) -> list[dict[str, any]]:
"""
Получаем список полей для заполнения [accrued_interest], [credit_sum];
те поля, у которых заполнены [payment], но не заполнены [credit_sum]
:param cursor: sqlite3.Cursor
:return: список полей для заполнения
"""
str_query = """
WITH [tbl_01_min_id_date] AS (
SELECT MAX([id_date]) AS [min_id_date]
FROM [tbl_credit_events]
WHERE [credit_sum] IS NOT NULL
AND [date_type_event] != 'noCharge'
)
, [tbl_02_max_id_date] AS (
SELECT MAX([id_date]) AS [max_id_date]
FROM [tbl_credit_events]
WHERE [payment] IS NOT NULL
AND [date_type_event] != 'noCharge'
)
SELECT [t1].[id_date]
, [t1].[date]
, [t1].[date_type_event]
, [t1].[days]
, [t1].[days_year]
, [t1].[credit_sum]
, [t1].[payment]
, [t1].[accrued_interest] -- всегда null
, [t1].[interest_rate]
FROM [tbl_credit_events] AS [t1], [tbl_01_min_id_date], [tbl_02_max_id_date]
WHERE [t1].[id_date] BETWEEN [tbl_01_min_id_date].[min_id_date] AND [tbl_02_max_id_date].[max_id_date];
"""
cursor.execute(str_query)
logging.debug("")
return cursor.fetchall()
def step_15_calculate_credit_sum_accrued_interest(dict_payment: list[dict[str, any]]) -> list[dict[str, any]]:
"""
Вычисление полей для обновления [accrued_interest], [credit_sum]
:param dict_payment: список полей для заполнения
:return: заполненный список полей
"""
is_first = True
accrued_interest_2 = 0
for elem in dict_payment:
if is_first:
is_first = False
credit_sum = elem["credit_sum"]
else:
if elem["date_type_event"] == "noCharge":
accrued_interest = round(credit_sum * elem["interest_rate"] * elem["days"] /
(100.0 * elem["days_year"]), 2)
elem["accrued_interest"] = accrued_interest
accrued_interest_2 = accrued_interest
else:
accrued_interest = round(
credit_sum * elem["interest_rate"] * elem["days"] / (100.0 * elem["days_year"]), 2)
elem["accrued_interest"] = accrued_interest
credit_sum = round(credit_sum + accrued_interest + accrued_interest_2 - elem["payment"], 2)
elem["credit_sum"] = credit_sum
accrued_interest_2 = 0
return dict_payment
def step_16_update_credit_sum_accrued_interest(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
dict_payment: list[dict[str, any]]) -> None:
"""
Обновление полей [accrued_interest], [credit_sum]
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param dict_payment: заполненный список полей
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [credit_sum] = :credit_sum
, [accrued_interest] = :accrued_interest
WHERE [id_date] = :id_date
"""
cursor.executemany(str_query, dict_payment)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_17_select_id_date_max(cursor: sqlite3.Cursor) -> dict[str, int]:
"""
Последняя дата с оплатой или на начало кредита если оплат ещё не было
:param cursor: sqlite3.Cursor
:return: id_date
"""
str_query = """
SELECT MAX([id_date]) AS [id_date_max]
FROM [tbl_credit_events]
WHERE (
[date_type_event] = 'realPayDate'
AND [is_payment_date] = 1
) OR (
[date_type_event] = 'creditStartDate'
AND [id_date] = 1
);
"""
cursor.execute(str_query)
dict_date_max = cursor.fetchone()
logging.debug("MAX([id_date]) for 'realPayDate' OR 'creditStartDate' = " + str(dict_date_max))
return dict_date_max
def step_18_select_last_credit_sum(cursor: sqlite3.Cursor, dict_id_date_max: dict[str, int]) -> float:
"""
[credit_sum] - оставшаяся сумма кредита на последнюю дату платежа (или не платежа)
:param cursor: sqlite3.Cursor
:param dict_id_date_max: последняя дата платежа (или не платежа)
:return: credit_sum
"""
str_query = """
SELECT [credit_sum]
FROM [tbl_credit_events]
WHERE [id_date] = :id_date_max;
"""
cursor.execute(str_query, dict_id_date_max)
flt_credit_sum = cursor.fetchone()["credit_sum"]
logging.debug("[credit_sum] for MAX([id_date]) of 'realPayDate' = " + str(flt_credit_sum))
return flt_credit_sum
def step_19_select_months_number(cursor: sqlite3.Cursor, dict_id_date_max: dict[str, int]) -> int:
"""
Кол-во оставшихся периодов оплаты
:param cursor: sqlite3.Cursor
:param dict_id_date_max: последняя дата платежа (или не платежа)
:return: Кол-во оставшихся периодов оплаты
"""
str_query = """
SELECT COUNT(1) AS [months]
FROM [tbl_credit_events]
WHERE [is_payment_date] = 1
AND [id_date] > :id_date_max
"""
cursor.execute(str_query, dict_id_date_max)
int_number_of_months = cursor.fetchone()["months"]
logging.debug("Number of months left = " + str(int_number_of_months))
return int_number_of_months
def step_20_select_month_ratios(cursor: sqlite3.Cursor, dict_id_date_max: dict[str, int]) -> dict[int, float]:
"""
Вычисление сведённых процентов для оставшихся периодов
:param cursor: sqlite3.Cursor
:param dict_id_date_max: последняя дата платежа (или не платежа)
:return: словарь со сведёнными процентами по оставшимся периодам
"""
str_query = """
WITH [tbl_01] AS (
SELECT [id_date]
FROM [tbl_credit_events]
WHERE [date_type_event] = 'planPayTheRestDate'
)
, [tbl_02] AS (
SELECT CASE
WHEN [tbl_01].[id_date] IS NULL THEN [t1].[id_payment]
WHEN [tbl_01].[id_date] <= [t1].[id_date] THEN [t1].[id_payment] - 1
ELSE [t1].[id_payment]
END AS [id_payment]
, [t1].[days]
, [t1].[days_year]
, [t1].[interest_rate]
FROM [tbl_credit_events] AS [t1]
LEFT JOIN [tbl_01]
ON [tbl_01].[id_date] <= [t1].[id_date]
WHERE [T1].[id_date] > :id_date_max
)
, [tbl_03] AS (
SELECT [id_payment]
, SUM([interest_rate] * [days] / [days_year]) AS [ratio]
FROM [tbl_02]
GROUP BY [id_payment]
)
SELECT ROW_NUMBER() OVER (ORDER BY [id_payment]) - 1 AS [id_payment]
, [ratio]
FROM [tbl_03];
"""
cursor.execute(str_query, dict_id_date_max)
list_ratios = cursor.fetchall()
dict_out = {}
for elem in list_ratios:
dict_out[elem["id_payment"]] = elem["ratio"]
logging.debug("[ratios]:\n" + str(dict_out))
return dict_out
def step_21_select_average_yearly_percent(cursor: sqlite3.Cursor, dict_id_date_max: dict[str, int]) -> float:
"""
Средний годовой процент, оставшегося периода
:param cursor: sqlite3.Cursor
:param dict_id_date_max: последняя дата платежа (или не платежа)
:return:
"""
str_query = """
SELECT SUM([interest_rate] * [days]) / SUM([days]) AS [avg_interest_rate]
FROM [tbl_credit_events]
WHERE [id_date] > :id_date_max
"""
cursor.execute(str_query, dict_id_date_max)
flt_avg_interest_rate = cursor.fetchone()["avg_interest_rate"]
logging.debug("avg interest_rate = " + str(flt_avg_interest_rate))
return flt_avg_interest_rate
def step_22_calculate_annuity_payment(flt_sum_in: float, flt_avg_yearly_percent: float, dict_ratios: dict[int, float],
int_months: int) -> dict[str, any]:
"""
Вычисляет аннуитетный платёж методом подбора / перебора
:param flt_sum_in: остаток тела долга на последнюю дату
:param flt_avg_yearly_percent: средний годовой процент, оставшегося периода
:param dict_ratios: словарь со сведёнными процентами по оставшимися периодам
:param int_months: месяцев до конца кредита
:return: аннуитетный платёж + тех информация
"""
c_flt_abs_success_threshold = 1.0 # успехом считается итоговая сумма остатка по кредита на последний месяц ниже этой отметки
c_int_max_iterations = 1000 # Максимальное кол-во итераций определения аннуитетного платежа
if int_months == 0: # Кредит закончился
dict_plan_payment = {
"payment": 0.0, # величина аннуитетного платежа
"message": {
"sucess": True, # удалось найти аннуитетный платеж? true / false
"iterations": 0 # Кол-во итераций до удачного / не удачного определения аннуитетного платежа
}
}
else:
# flt_payment = flt_sum_in * (1 / int_months + flt_yearly_percent / (12 * 100)) # не влияет на кол-во итераций "int_iter"
flt_interm = (1 + flt_avg_yearly_percent / (12 * 100)) ** int_months
flt_payment = flt_sum_in * flt_avg_yearly_percent * flt_interm / (12 * 100) / (flt_interm - 1)
del flt_interm
int_payment_pow_offset = len(str(int(flt_payment))) - 2
int_iter = 0
b_proceed = True
b_sucess = False
while b_proceed:
int_iter += 1
flt_sum = flt_sum_in
for i in range(int_months):
flt_accrued = flt_sum * dict_ratios[i] / 100
flt_sum = flt_sum + flt_accrued - flt_payment
if abs(flt_sum) <= c_flt_abs_success_threshold:
b_proceed = False
b_sucess = True
elif int_iter == c_int_max_iterations: # на всякий случай, прерывание при 1000-й операции
b_proceed = False
b_sucess = False
elif flt_sum < 0:
flt_payment = flt_payment - 10 ** int_payment_pow_offset
elif flt_sum > 0:
flt_payment = flt_payment + 10 ** int_payment_pow_offset
int_payment_pow_offset -= 1
flt_payment = flt_payment - 10 ** int_payment_pow_offset
dict_plan_payment = {
"payment": round(flt_payment, 2), # величина аннуитетного платежа
"message": {
"sucess": b_sucess, # удалось найти аннуитетный платеж? true / false
"iterations": int_iter # Кол-во итераций до удачного / не удачного определения аннуитетного платежа
}
}
if b_sucess:
logging.debug("аннуитетный платеж найден\n" + json.dumps(dict_plan_payment, indent=2))
else:
logging.error("Ошибка в расчётах аннуитетного платежа\n" + json.dumps(dict_plan_payment, indent=2))
return dict_plan_payment
def step_23_to_update_plan_pay_the_rest_date(cursor: sqlite3.Cursor) -> bool:
"""
Проверяем на наличие [date_type_event] = 'planPayTheRestDate'
:param cursor: sqlite3.Cursor
:return: True если есть 'planPayTheRestDate' / False если нет
"""
str_query = """
SELECT count(1) AS [num]
FROM [tbl_credit_events]
WHERE [date_type_event] = 'planPayTheRestDate';
"""
cursor.execute(str_query)
if cursor.fetchone()["num"] == 0:
logging.debug("No 'planPayTheRestDate' found")
return False
else:
logging.debug("'planPayTheRestDate' found, need update")
return True
def step_24_select_last_payment(cursor: sqlite3.Cursor) -> float:
"""
Получаем [payment] на дату последнего реального платежа
:param cursor: sqlite3.Cursor
:return:
"""
str_query = """
WITH [tbl_01] AS (
SELECT MAX([id_date]) AS [id_date]
FROM [tbl_credit_events]
WHERE [date_type_event] IN ('creditStartDate', 'realPayDate')
)
SELECT [payment]
FROM [tbl_credit_events]
WHERE [id_date] = (
SELECT [id_date]
FROM [tbl_01]
);
"""
cursor.execute(str_query)
flt_payment = cursor.fetchone()["payment"]
logging.debug("last payment = " + str(flt_payment))
return flt_payment
def step_25_update_accrued_interest_and_credit_sum(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Обновляем запись для [date_type_event] = 'planPayTheRestDate'
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
WITH [tbl_01] AS (
SELECT [id_date], [days], [days_year]
FROM [tbl_credit_events]
WHERE [date_type_event] = 'planPayTheRestDate'
)
, [tbl_02] AS (
SELECT [tbl_01].[id_date], [tbl_01].[days], [tbl_01].[days_year]
, [T1].[credit_sum], [T1].[days], [T1].[days_year], [T1].[interest_rate]
FROM [tbl_credit_events] AS [T1]
INNER JOIN [tbl_01]
ON [T1].[id_date] + 1 = [tbl_01].[id_date]
)
, [tbl_03] AS (
SELECT [id_date]
, ROUND([credit_sum] * [interest_rate] * [days] / (100.0 * [days_year]), 2) AS [payment]
, [credit_sum]
FROM [tbl_02]
)
UPDATE [tbl_credit_events] AS [T1]
SET [payment] = [tbl_03].[payment]
, [accrued_interest] = [tbl_03].[payment]
, [credit_sum] = [tbl_03].[credit_sum]
FROM [tbl_03]
WHERE [T1].[id_date] = [tbl_03].[id_date]
AND [T1].[date_type_event] = 'planPayTheRestDate';
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_26_select_id_date_max(cursor: sqlite3.Cursor) -> dict[str, int]:
"""
Плановая дата платежа, после реальной даты платежа, которая НЕ совпала с плановой
:param cursor: sqlite3.Cursor
:return: id_date
"""
str_query = """
SELECT [id_date] AS [id_date_max]
FROM [tbl_credit_events]
WHERE [date_type_event] = 'planPayTheRestDate';
"""
cursor.execute(str_query)
dict_date_max = cursor.fetchone()
logging.debug("MAX([id_date]) for 'realPayDate' = " + str(dict_date_max))
return dict_date_max
def step_27_update_payment_for_plan_the_rest_date(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool,
flt_last_payment: float) -> None:
"""
Обновляем запись для [date_type_event] = 'planPayTheRestDate'
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param flt_last_payment: Сумма платежа на дату
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [payment] = ROUND(?, 2)
WHERE [date_type_event] = 'planPayTheRestDate'
"""
cursor.execute(str_query, (flt_last_payment,))
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_28_update_payment(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool, dict_payment: dict) -> None:
"""
Обновление аннуитетного платежа вплоть до окончания кредита
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:param dict_payment: аннуитетный платеж
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [payment] = :payment
WHERE [date_type_event] = 'planPayDate';
"""
cursor.execute(str_query, dict_payment)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_29_update_principal_debt(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Обновляем [principal_debt] тело долга
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
UPDATE [tbl_credit_events]
SET [principal_debt] = ROUND([payment] - [accrued_interest], 2);
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_30_update_the_last_date(cursor: sqlite3.Cursor, b_show_tbls_in_debug: bool) -> None:
"""
Корректируем самый последний платёж. Приводим [credit_sum] к 0.0; + прочие правки
Реальные платежи не корректируются
:param cursor: sqlite3.Cursor
:param b_show_tbls_in_debug: Показывать в логах содержимое таблиц после операции для уровня логирования DEBUG
:return:
"""
str_query = """
WITH [tbl_01] AS (
SELECT MAX([id_date]) AS [max_id_date]
FROM [tbl_credit_events]
WHERE [date_type_event] != 'realPayDate'
)
, [tbl_02] AS (
SELECT [id_date] AS [max_id_date]
, [credit_sum], [payment], [accrued_interest], [principal_debt]
FROM [tbl_credit_events]
WHERE [id_date] = (
SELECT [max_id_date]
FROM [tbl_01]
)
)
, [tbl_03] AS (
SELECT [max_id_date]
, ROUND([payment] + [credit_sum], 2) AS [payment]
, ROUND([payment] + [credit_sum] - [accrued_interest], 2) AS [principal_debt]
FROM [tbl_02]
)
UPDATE [tbl_credit_events] AS [T1]
SET [credit_sum] = 0.0
, [payment] = [tbl_03].[payment]
, [principal_debt] = [tbl_03].[principal_debt]
FROM [tbl_03]
WHERE [T1].[id_date] = [tbl_03].[max_id_date];
"""
cursor.execute(str_query)
if b_show_tbls_in_debug:
logging.debug("[tbl_credit_events]\n" + step_99_select_all_from_tbl(cursor, "tbl_credit_events"))
def step_31_select_tbl_credit_events(cursor: sqlite3.Cursor) -> list[dict[str, any]]:
"""
Получение содержимого таблиц в формате словаря
:param cursor: sqlite3.Cursor
:return:
"""
str_query = """
SELECT *
FROM [tbl_credit_events]
"""
cursor.execute(str_query)
return cursor.fetchall()
def step_32_annuity_payment_schedule(cursor: sqlite3.Cursor) -> list[dict[str, any]]:
"""
График погашения кредита в привычном виде
:param cursor: sqlite3.Cursor
:return:
"""
str_query = """
SELECT *
FROM [v_credit];
"""
cursor.execute(str_query)
return cursor.fetchall()
def step_33_test_last_payment(cursor: sqlite3.Cursor) -> bool:
"""
Тест. Сравнение последнего и предпоследнего платежа. По сути проверяется качество вычисления аннуитетного платежа,
т.к. в последний платёж вливаются все накопленные ошибки вычисления аннуитетного платежа за все периоды
:param cursor: sqlite3.Cursor
:return: true - валидный / false - не валидный
"""
c_flt_threshold = 0.01
str_query = """
WITH [tbl_01] AS (
SELECT MAX([id_date]) AS [max_id_date]
FROM [tbl_credit_events]
)
SELECT
CASE
WHEN [T2].[payment] = 0.0 THEN 0.0
ELSE ABS([T1].[payment] / [T2].[payment] - 1.0)
END AS [ratio]
, ROUND([T1].[payment] - [T2].[payment], 2) AS [delta]
FROM [tbl_credit_events] AS [T1], [tbl_credit_events] AS [T2]
WHERE [t1].[id_date] = (
SELECT [max_id_date]
FROM [tbl_01]
)
AND [t2].[id_date] = (
SELECT [max_id_date] - 1
FROM [tbl_01]
);
"""
cursor.execute(str_query)
tpl_flt_test = cursor.fetchone()
if tpl_flt_test["ratio"] <= c_flt_threshold:
logging.debug("Тест пройден. Отношение последнего платежа к предпоследнему меньше порогового значения:\n" +
"значение полученное {} <= порог {}\n".format(tpl_flt_test["ratio"], c_flt_threshold) +
"Разница: {}".format(tpl_flt_test["delta"]))
return True
else:
logging.error("Тест не пройден! Отношение последнего платежа к предпоследнему больше порогового значения:\n" +
"значение полученное {} > порог {}\n".format(tpl_flt_test["ratio"], c_flt_threshold) +
"Разница: {}".format(tpl_flt_test["delta"]))
return False
def step_34_test_principal_debt(cursor: sqlite3.Cursor) -> bool:
"""
Тест. Первоначальная величина кредита должна совпадать с оплаченным телом долга
:param cursor: sqlite3.Cursor
:return: true - валидный / false - не валидный
"""
c_flt_threshold = 0.01
str_query = """
WITH [tbl_01] AS (
SELECT [credit_sum]
FROM [tbl_credit_events]
WHERE [id_date] = 1
)
, [tbl_02] AS (
SELECT SUM([principal_debt]) AS [principal_debt]
FROM [tbl_credit_events]
)
SELECT ROUND([tbl_01].[credit_sum] - [tbl_02].[principal_debt], 2) AS [delta]
FROM [tbl_01], [tbl_02];
"""
cursor.execute(str_query)
flt_delta = cursor.fetchone()["delta"]
if flt_delta <= c_flt_threshold:
logging.debug("Тест пройден. Первоначальная величина кредита совпадает с оплаченным телом долга." +
"Разница: {}".format(flt_delta))
return True
else:
logging.error("Тест не пройден! Первоначальная величина кредита не совпадает с оплаченным телом долга." +
"Большая разница: {}".format(flt_delta))
return False
def step_35_test_rows(cursor: sqlite3.Cursor) -> bool:
"""
Тест. Для записей [date_type_event] = 'noCharge' [accrued_interest] + [principal_debt] должен быть всегда 0.0,
для остальных записей [principal_debt] != 'noCharge' должен быть не меньше 0.0
:param cursor: sqlite3.Cursor
:return: true - валидный / false - не валидный
"""
str_query = """
SELECT [id_date], [date_type_event], [date]
, [credit_sum], [payment], [accrued_interest], [principal_debt]
FROM [tbl_credit_events]
WHERE (
[date_type_event] = 'noCharge'
AND ROUND([accrued_interest] + [principal_debt], 2) != 0
) OR (
[date_type_event] != 'noCharge'
AND [principal_debt] < 0.0
);
"""
cursor.execute(str_query)
rows = cursor.fetchall()
if len(rows) == 0:
logging.debug("Тест пройден")
return True
else:
logging.error("Тест не пройден\n[id_date], [date_type_event]" +
", [date], [credit_sum], [payment], [accrued_interest], [principal_debt]")
for row in rows:
logging.error(", ".join(map(str, row.values())))
return False
def step_36_validate_output(dict_in: list[dict]) -> bool:
"""
Валиадция формата финального ответа
:param dict_in: словарь на валидацию
:return: true - валидный / false - не валидный
"""
try:
jsonschema.validate(instance=dict_in, schema=get_json_schema_output())
except jsonschema.exceptions.ValidationError as exp:
logging.error(exp)
b_success = False
else:
logging.debug("Формат выходного сообщения валидный")
b_success = True
return b_success
# ==================================================<tests>==================================================
def test_step_01_test_scenario_list() -> list[dict[str, str]]:
"""
Список тестовых сценариев
:return:
"""
return [
{
"date_from": "2023-12-20",
"description": "Начало кредита"
},
{
"date_from": "2024-01-21",
"description": "платёж перед датой planPayTheRestDate >= аннуитетного платёжа"
},
{
"date_from": "2024-03-30",
"description": "платёж перед датой planPayTheRestDate < аннуитетный платёжа"
},
{
"date_from": "2024-01-20",
"description": "Обычный аннуитетный платёж"
},
{
"date_from": "2024-11-20",
"description": "Кредит полностью погашен"
}
]
def test_step_02_remove_dates_in_list(list_payments_in: list[dict[str, any]], str_date_remove_more: str) -> list[dict[str, any]]:
"""
Удаление лишних платежей
:param list_payments_in: Список платежей
:param str_date_remove_more: Если дата платежа из списка больше даты str_date_remove_more, то платёж удаляется из
списка
:return: Список платежей
"""
int_len = len(list_payments_in)
for i in range(int_len - 1, -1, -1):
if datetime.date.fromisoformat(list_payments_in[i]["date"]) > datetime.date.fromisoformat(str_date_remove_more):
list_payments_in.pop(i)
return list_payments_in
# ==================================================</tests>==================================================
# ==================================================<utilities>==================================================
def logging_basic_config(logging_level: int) -> None:
"""
Настройка логирования
:return: None
"""
logging.basicConfig(
level=logging_level,
encoding="utf-8",
format="\t".join([
"%(asctime)s",
"file: %(filename)s",
"def: %(funcName)s",
"Script line #:%(lineno)d",
"%(levelname)s",
"%(message)s"
])
)
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict[str, any]:
"""
Настраиваем ответы на запросы от БД в виде словаря
:param cursor: sqlite3.Cursor
:param row:
:return:
"""
dict_row = {}
for idx, col in enumerate(cursor.description):
dict_row[col[0]] = row[idx]
return dict_row
def step_99_select_all_from_tbl(cursor: sqlite3.Cursor, str_table_name: str) -> str:
"""
Получение содержимого таблиц в формате таблицы
:param cursor: sqlite3.Cursor
:param str_table_name: Название таблицы
:return:
"""
if str_table_name == "tbl_credit_events":
str_query = """
SELECT *
FROM [tbl_credit_events]
"""
elif str_table_name == "tbl_date":
str_query = """
SELECT *
FROM [tbl_date];
"""
else:
logging.error("str_table_name неверный")
str_query = "SELECT 1 AS [dummy]"
cursor.execute(str_query)
return step_99_select_tab_join(cursor)
def step_99_select_tab_join(cursor: sqlite3.Cursor) -> str:
"""
Содержимое ответа на запрос переводится в таблицу
:param cursor: sqlite3.Cursor
:return: таблица
"""
list_row_out = []
column_list = []
for column in cursor.description:
column_list.append(column[0])
list_row_out.append("\t".join(column_list))
rows = cursor.fetchall()
for row in rows:
column_list = []
for column in cursor.description:
column_list.append(str(row[column[0]]))
list_row_out.append("\t".join(column_list))
return "\n".join(list_row_out)
def step_99_print_table_from_dict(list_dict_in: list[dict]) -> None:
"""
Из словаря делаем таблицу и возвращаем через print()
:param list_dict_in: Словарь на вход, который преобразоывается в такблицу
:return: None
"""
b_first = True
for row in list_dict_in:
if b_first:
b_first = False
print("\t".join(row))
print("\t".join(map(str, row.values())))
# ==================================================</utilities>==================================================
# ==================================================<business_logic>==================================================
def get_json_schema_input() -> dict[str, any]:
"""
Описание и валидация условий кредита
:return:
"""
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Credit input",
"description": "Описание и валидация условий кредита",
"required": ["credit", "payments"],
"type": "object",
"additionalProperties": True,
"properties": {
"credit": {
"type": "object",
"additionalProperties": True,
"required": ["sum", "yearly_percent", "years_span", "date_start"],
"properties": {
"sum": {
"type": "number",
"description": "Сумма кредита",
"nullable": False,
"exclusiveMinimum": 0,
"example": 1000.01
},
"yearly_percent": {
"type": "number",
"description": "Годовая процентная ставка, т.е. для конкретного примера 8.2% вносим 8.2 в значение",
"nullable": False,
"exclusiveMinimum": 0,
"example": 8.2
},
"years_span": {
"type": "number",
"description": "На сколько лет берётся кредит",
"nullable": False,
"exclusiveMinimum": 0,
"example": 4
},
"date_start": {"$ref": "#/$defs/date-type"}
}
},
"payments": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["date", "payment"],
"properties": {
"date": {"$ref": "#/$defs/date-type"},
"payment": {
"type": "number",
"description": "Величина платежа",
"nullable": False,
"exclusiveMinimum": 0,
"example": 100.2
}
}
}
}
},
"$defs": {
"date-type": {
"type": "string",
"description": "Дата в формате ISO 8601 (YYYY-MM-DD)",
"nullable": False,
"maxLength": 10,
"minLength": 10,
"example": "2020-07-12",
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"
}
}
}
def get_json_schema_output() -> dict[str, any]:
"""
Описание и валидация графика платежей
:return:
"""
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Credit output",
"description": "Описание и валидация графика платежей",
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["id_payment", "date", "days", "credit_sum", "payment",
"accrued_interest", "principal_debt", "interest_rate"],
"properties": {
"id_date": {
"type": "integer",
"description": "id даты",
"nullable": False,
"exclusiveMinimum": 0,
"example": 10
},
"id_payment": {
"type": "integer",
"description": "id платежа",
"nullable": False,
"minimum": 0,
"example": 10
},
"is_payment_date": {
"type": "integer",
"description": "Является ли датой платежа",
"nullable": False,
"enum": [0, 1]
},
"date_type_event": {
"type": "string",
"description": "Типы событий",
"nullable": False,
"enum": ["creditStartDate", "realPayDate", "planPayTheRestDate", "planPayDate", "noCharge"]
},
"date": {
"type": "string",
"description": "Дата в формате ISO 8601 (YYYY-MM-DD)",
"nullable": False,
"maxLength": 10,
"minLength": 10,
"example": "2020-07-12",
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"
},
"days": {
"type": ["number", "null"],
"description": "Сколько дней прошло с предыдущей даты",
"minimum": 1.0,
"maximum": 31.0,
"example": 11.0
},
"days_year": {
"type": ["number", "null"],
"description": "Дней в году",
"enum": [365.0, 366.0, None]
},
"credit_sum": {
"type": "number",
"description": "Оставшаяся сумма кредита на дату",
"nullable": False,
"minimum": 0,
"example": 111_111.1
},
"payment": {
"type": ["number", "null"],
"description": "Сумма платежа на дату (= accrued_interest + principal_debt)",
"minimum": 0,
"example": 543.2
},
"accrued_interest": {
"type": ["number", "null"],
"description": "Начисленные проценты по кредиту за обозначенный период",
"minimum": 0,
"example": 111.1
},
"principal_debt": {
"type": ["number", "null"],
"description": "Тело долга",
"example": 432.1
},
"interest_rate": {
"type": ["number", "null"],
"description": "Процент по кредиту, действующий за обозначенный период",
"nullable": False,
"exclusiveMinimum": 0,
"example": 7.1
}
}
}
}
# ==================================================</business_logic>==================================================
if __name__ == "__main__":
if c_dict_global["b_test_mode"]:
test()
else:
main()
Установка
Копируем оба файла
Устанавливаем jsonschema
Все важные элементы управления вынесены c_dict_global в credit_annuity_payment_schedule.py. Нужно настроить "paths" - Пути к файлам
c_dict_global
c_dict_global = { # Глобальные переменные
"paths": { # Пути к файлам
"json": r"..\credit_terms.json", # Где лежит собственно файл, который нужно рассчитать
"test_json": r"..\credit_terms.json" # Где лежит файл основы для тестовых сценариев
},
...
Обращу внимание, что тесты def test_step_01_test_scenario_list() настроены на выше описанный вариант credit_terms.json
Всё
Итоги
На этом всё, спасибо за внимание. Надеюсь было полезно, пользуйтесь по мере необходимости.
Комментарии (5)
vshemarov
25.01.2025 05:08В целом интересно, конечно, но если взял кредит по ставке ниже, чем сегодняшний депозит, то нет никакого смысла заморачиваться с досрочным погашением
Dkstr
25.01.2025 05:08Как ресурс еще бы добавил https://calculoan.ru/
Из фишек можно добавлять досрочные погашения в расчет
dzmitry_li
У нас банки пошли хитрее. Отказались от аннуитетных платежей, перешли на дифференцированный (включая кредиты на квартиру, авто). При этом дифференцированный тоже с подвохом - платить можешь и больше, но в следующем месяце твой основной долг будет 0, плати только процент на остаток. И так, пока не дойдёшь до твоей окончания переплаты.
Как погашается окончательный долг? - А это отдельный вид погашения. Если банк не сумел это сделать по оплате через интернет, то надо идти в банк, делать последний платёж. Некоторые банки даже не сумели сделать автоматический расчёт при оплате по графику, и всё равно надо идти в банк делать последний платёж в кассу.
Но аннуитетные так же не исчезли. Где-то 70/30 в пользу дифференцированных платежей.
Кредиты зло!
vshemarov
Кредиты зло, когда ты их не понимаешь, не умеешь считать и планировать. Если же понимаешь, во что тебе это обойдется в итоге, сколько ты будешь отдавать в месяц, как и откуда возьмутся деньги для погашения, то все нормально.