From 10b370f11b3e5565216653e218c0a428bf76735e Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 15 Feb 2026 14:33:14 +0100 Subject: [PATCH] 15.02.26 --- modules/purchase_trade/fee.py | 2 +- modules/purchase_trade/financing_tools.py | 259 ++++++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 modules/purchase_trade/financing_tools.py diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 8ca056d..ffbd2a4 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -334,7 +334,7 @@ class Fee(ModelSQL,ModelView): if self.sale_line: if self.sale_line.sale.payment_term: beg_date = self.fee_date if self.fee_date else Date.today() - est_date = self.sale_line.sale.payment_term.get_date(beg_date) + est_date = self.sale_line.sale.payment_term[0].get_date(beg_date) if est_date and beg_date: factor = InterestCalculator.calculate( start_date=beg_date, diff --git a/modules/purchase_trade/financing_tools.py b/modules/purchase_trade/financing_tools.py new file mode 100644 index 0000000..6a7aa5b --- /dev/null +++ b/modules/purchase_trade/financing_tools.py @@ -0,0 +1,259 @@ +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 + }