260 lines
7.2 KiB
Python
260 lines
7.2 KiB
Python
from decimal import Decimal, getcontext
|
||
from datetime import datetime, date
|
||
from calendar import isleap
|
||
from typing import Callable, Dict
|
||
import uuid
|
||
|
||
getcontext().prec = 28
|
||
|
||
# {
|
||
# "computation_type": "INTEREST_ACCRUAL",
|
||
# "input": {
|
||
# "start_date": "2026-01-01",
|
||
# "end_date": "2026-06-30",
|
||
# "notional": "1000000",
|
||
# "rate": {
|
||
# "value": "0.08",
|
||
# "type": "ANNUAL"
|
||
# },
|
||
# "day_count_convention": "ACT/360",
|
||
# "compounding_method": "SIMPLE"
|
||
# }
|
||
# }
|
||
|
||
# result = FinancialComputationService.execute(payload)
|
||
# interest = Decimal(result["result"]["interest_amount"])
|
||
# interest = currency.round(interest)
|
||
|
||
# ============================================================
|
||
# VERSIONING
|
||
# ============================================================
|
||
|
||
ENGINE_VERSION = "1.0.0"
|
||
|
||
|
||
# ============================================================
|
||
# REGISTRY (PLUGIN SYSTEM)
|
||
# ============================================================
|
||
|
||
DAY_COUNT_REGISTRY: Dict[str, Callable] = {}
|
||
COMPOUNDING_REGISTRY: Dict[str, Callable] = {}
|
||
|
||
|
||
def register_day_count(name: str):
|
||
def decorator(func):
|
||
DAY_COUNT_REGISTRY[name] = func
|
||
return func
|
||
return decorator
|
||
|
||
|
||
def register_compounding(name: str):
|
||
def decorator(func):
|
||
COMPOUNDING_REGISTRY[name] = func
|
||
return func
|
||
return decorator
|
||
|
||
|
||
# ============================================================
|
||
# DOMAIN – DAY COUNT CONVENTIONS
|
||
# ============================================================
|
||
|
||
@register_day_count("ACT/360")
|
||
def act_360(start: date, end: date) -> Decimal:
|
||
return Decimal((end - start).days) / Decimal(360)
|
||
|
||
|
||
@register_day_count("ACT/365F")
|
||
def act_365f(start: date, end: date) -> Decimal:
|
||
return Decimal((end - start).days) / Decimal(365)
|
||
|
||
|
||
@register_day_count("ACT/ACT_ISDA")
|
||
def act_act_isda(start: date, end: date) -> Decimal:
|
||
total = Decimal("0")
|
||
current = start
|
||
|
||
while current < end:
|
||
year_end = date(current.year, 12, 31)
|
||
next_year = date(current.year + 1, 1, 1)
|
||
period_end = min(next_year, end)
|
||
|
||
days = (period_end - current).days
|
||
year_days = 366 if isleap(current.year) else 365
|
||
|
||
total += Decimal(days) / Decimal(year_days)
|
||
current = period_end
|
||
|
||
return total
|
||
|
||
|
||
@register_day_count("30E/360")
|
||
def thirty_e_360(start: date, end: date) -> Decimal:
|
||
d1 = min(start.day, 30)
|
||
d2 = min(end.day, 30)
|
||
|
||
days = (
|
||
(end.year - start.year) * 360 +
|
||
(end.month - start.month) * 30 +
|
||
(d2 - d1)
|
||
)
|
||
|
||
return Decimal(days) / Decimal(360)
|
||
|
||
|
||
# ============================================================
|
||
# DOMAIN – COMPOUNDING STRATEGIES
|
||
# ============================================================
|
||
|
||
@register_compounding("SIMPLE")
|
||
def simple(rate: Decimal, yf: Decimal) -> Decimal:
|
||
return rate * yf
|
||
|
||
|
||
@register_compounding("ANNUAL")
|
||
def annual(rate: Decimal, yf: Decimal) -> Decimal:
|
||
return (Decimal(1) + rate) ** yf - Decimal(1)
|
||
|
||
|
||
@register_compounding("MONTHLY")
|
||
def monthly(rate: Decimal, yf: Decimal) -> Decimal:
|
||
monthly_rate = rate / Decimal(12)
|
||
months = yf * Decimal(12)
|
||
return (Decimal(1) + monthly_rate) ** months - Decimal(1)
|
||
|
||
|
||
@register_compounding("CONTINUOUS")
|
||
def continuous(rate: Decimal, yf: Decimal) -> Decimal:
|
||
from math import exp
|
||
return Decimal(exp(float(rate * yf))) - Decimal(1)
|
||
|
||
|
||
# ============================================================
|
||
# DOMAIN – INTEREST COMPUTATION OBJECT
|
||
# ============================================================
|
||
|
||
class InterestComputation:
|
||
|
||
def __init__(
|
||
self,
|
||
start_date: date,
|
||
end_date: date,
|
||
notional: Decimal,
|
||
rate_value: Decimal,
|
||
rate_type: str,
|
||
day_count: str,
|
||
compounding: str,
|
||
):
|
||
self.start_date = start_date
|
||
self.end_date = end_date
|
||
self.notional = notional
|
||
self.rate_value = rate_value
|
||
self.rate_type = rate_type
|
||
self.day_count = day_count
|
||
self.compounding = compounding
|
||
|
||
def compute(self):
|
||
|
||
if self.end_date <= self.start_date:
|
||
raise ValueError("end_date must be after start_date")
|
||
|
||
if self.day_count not in DAY_COUNT_REGISTRY:
|
||
raise ValueError("Unsupported day count convention")
|
||
|
||
if self.compounding not in COMPOUNDING_REGISTRY:
|
||
raise ValueError("Unsupported compounding method")
|
||
|
||
yf = DAY_COUNT_REGISTRY[self.day_count](
|
||
self.start_date,
|
||
self.end_date
|
||
)
|
||
|
||
# Normalize rate to annual
|
||
if self.rate_type == "MONTHLY":
|
||
annual_rate = self.rate_value * Decimal(12)
|
||
else:
|
||
annual_rate = self.rate_value
|
||
|
||
factor = COMPOUNDING_REGISTRY[self.compounding](
|
||
annual_rate,
|
||
yf
|
||
)
|
||
|
||
interest_amount = self.notional * factor
|
||
|
||
return {
|
||
"year_fraction": yf,
|
||
"interest_factor": factor,
|
||
"interest_amount": interest_amount,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# APPLICATION LAYER – JSON SERVICE (Camunda Ready)
|
||
# ============================================================
|
||
|
||
class FinancialComputationService:
|
||
|
||
@staticmethod
|
||
def execute(payload: dict) -> dict:
|
||
"""
|
||
Stateless JSON entrypoint.
|
||
Compatible Camunda / REST / Tryton bridge.
|
||
"""
|
||
|
||
try:
|
||
request_id = str(uuid.uuid4())
|
||
|
||
input_data = payload["input"]
|
||
|
||
start_date = datetime.strptime(
|
||
input_data["start_date"], "%Y-%m-%d"
|
||
).date()
|
||
|
||
end_date = datetime.strptime(
|
||
input_data["end_date"], "%Y-%m-%d"
|
||
).date()
|
||
|
||
notional = Decimal(input_data["notional"])
|
||
rate_value = Decimal(input_data["rate"]["value"])
|
||
rate_type = input_data["rate"]["type"].upper()
|
||
day_count = input_data["day_count_convention"]
|
||
compounding = input_data["compounding_method"]
|
||
|
||
computation = InterestComputation(
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
notional=notional,
|
||
rate_value=rate_value,
|
||
rate_type=rate_type,
|
||
day_count=day_count,
|
||
compounding=compounding,
|
||
)
|
||
|
||
result = computation.compute()
|
||
|
||
return {
|
||
"metadata": {
|
||
"engine_version": ENGINE_VERSION,
|
||
"request_id": request_id,
|
||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||
},
|
||
"result": {
|
||
"year_fraction": str(result["year_fraction"]),
|
||
"interest_factor": str(result["interest_factor"]),
|
||
"interest_amount": str(result["interest_amount"]),
|
||
},
|
||
"explainability": {
|
||
"formula": "Interest = Notional × Factor",
|
||
"factor_definition": f"{compounding} compounding applied to annualized rate",
|
||
"day_count_used": day_count
|
||
},
|
||
"status": "SUCCESS"
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"status": "ERROR",
|
||
"message": str(e),
|
||
"engine_version": ENGINE_VERSION
|
||
}
|