PABPAT/TCI_Shield
0
1# ============================================================2# TRADE CREDIT INSURANCE -- UNDERWRITING RULES ENGINE3# ============================================================4# Final Score = (Business Profile x 0.30)5# + (Financial Ratios x 0.40)6# + (Buyer Portfolio x 0.30)7#8# Each stream scored 0-100, weighted, then combined.9# Decline threshold: Final Score >= 7510# Auto decline: Negative TNW11#12# Buyer Risk = (Country Risk x 0.40)13# + (Industry Risk x 0.40)14# + (Customer Risk x 0.20)15# ============================================================16 17import math18import logging19 20logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")21 22# ============================================================23# SECTION 1 -- RISK REFERENCE DATA24# ============================================================25 26# Industry sectors -- risk score and off-cover limit27# Off-cover limit = 75 - industry score (riskier = lower tolerance)28# Score 5 industries get limit of 75 (hardest to breach)29INDUSTRY_RISK = {30 "construction": {"score": 25, "off_cover_limit": 75 - 25}, # 5031 "retail": {"score": 20, "off_cover_limit": 75 - 20}, # 5532 "hospitality": {"score": 20, "off_cover_limit": 75 - 20}, # 5533 "transportation":{"score": 15, "off_cover_limit": 75 - 15}, # 6034 "manufacturing": {"score": 10, "off_cover_limit": 75 - 10}, # 6535 "wholesale": {"score": 10, "off_cover_limit": 75 - 10}, # 6536 "technology": {"score": 5, "off_cover_limit": 75 - 0}, # 7537 "professional": {"score": 5, "off_cover_limit": 75 - 0}, # 7538 "food_beverage": {"score": 15, "off_cover_limit": 75 - 15}, # 6039 "healthcare": {"score": 5, "off_cover_limit": 75 - 0}, # 7540 "other": {"score": 15, "off_cover_limit": 75 - 15}, # 6041}42 43# Trade type risk44TRADE_TYPE_RISK = {45 "export": 7,46 "domestic": 4,47 "both": 6,48}49 50# Rating to Score conversion51# ---------------------------------------------------------------52# Formula: ceil(base_value + (base_value * pos * factor) + (factor * pos))53# base_value = 254# factor = 0.7555# pos = slab position (A1=0, A2=1, B1=2, B2=3, C1=4, C2=5, D=6)56#57# C Group additional: + (0.5 * no. of C slabs at or below rating)58# C1 = +0.5 * 1 = 0.559# C2 = +0.5 * 2 = 1.060#61# D Group additional: + 1.562#63# Examples:64# A1: ceil(2) = 265# A2: ceil(2 + 2*1*0.75 + 0.75*1) = ceil(4.25) = 566# B1: ceil(2 + 2*2*0.75 + 0.75*2) = ceil(6.50) = 767# B2: ceil(2 + 2*3*0.75 + 0.75*3) = ceil(8.75) = 968# C1: ceil(2 + 2*4*0.75 + 0.75*4 + 0.5*1) = ceil(11.50) = 1269# C2: ceil(2 + 2*5*0.75 + 0.75*5 + 0.5*2) = ceil(14.75) = 1570# D: ceil(2 + 2*6*0.75 + 0.75*6 + 0.5*2 + 1.5) = ceil(18.00) = 1871# ---------------------------------------------------------------72RATING_TO_SCORE = {73 "A1": 2,74 "A2": 5,75 "B1": 7,76 "B2": 9,77 "C1": 12,78 "C2": 15,79 "D": 18,80}81 82# Country risk ratings83COUNTRY_RISK = {84 # A1 -- Insignificant Risk85 "american samoa": "A1",86 "anguilla": "A1",87 "aruba": "A1",88 "australia": "A1",89 "austria": "A1",90 "bermuda": "A1",91 "bonaire": "A1",92 "british pacific islands": "A1",93 "british virgin islands": "A1",94 "cayman islands": "A1",95 "channel isles": "A1",96 "christmas island": "A1",97 "cocos island": "A1",98 "cook islands": "A1",99 "curacao": "A1",100 "czech republic": "A1",101 "estonia": "A1",102 "falkland islands": "A1",103 "germany": "A1",104 "gibraltar": "A1",105 "guam": "A1",106 "heard island": "A1",107 "iceland": "A1",108 "india": "A1",109 "italy": "A1",110 "japan": "A1",111 "montserrat": "A1",112 "netherlands": "A1",113 "new zealand": "A1",114 "niue island": "A1",115 "norfolk island": "A1",116 "northern mariana islands": "A1",117 "norway": "A1",118 "palau": "A1",119 "puerto rico": "A1",120 "san marino": "A1",121 "singapore": "A1",122 "sint maarten": "A1",123 "south korea": "A1",124 "st. helena": "A1",125 "sweden": "A1",126 "switzerland": "A1",127 "tokelau": "A1",128 "turks and caicos islands": "A1",129 "united kingdom": "A1",130 "uk": "A1",131 "united states": "A1",132 "usa": "A1",133 "us minor outlying islands": "A1",134 "us virgin islands": "A1",135 136 # A2 -- Low Risk137 "andorra": "A2",138 "bahrain": "A2",139 "belgium": "A2",140 "bhutan": "A2",141 "botswana": "A2",142 "brazil": "A2",143 "bulgaria": "A2",144 "canada": "A2",145 "canary islands": "A2",146 "croatia": "A2",147 "cyprus": "A2",148 "denmark": "A2",149 "faroe islands": "A2",150 "finland": "A2",151 "france": "A2",152 "french guiana": "A2",153 "french polynesia": "A2",154 "greenland": "A2",155 "guadeloupe": "A2",156 "guyana": "A2",157 "indonesia": "A2",158 "ireland": "A2",159 "kuwait": "A2",160 "latvia": "A2",161 "liechtenstein": "A2",162 "lithuania": "A2",163 "luxembourg": "A2",164 "malaysia": "A2",165 "malta": "A2",166 "martinique": "A2",167 "mauritius": "A2",168 "mayotte": "A2",169 "mexico": "A2",170 "monaco": "A2",171 "new caledonia": "A2",172 "oman": "A2",173 "philippines": "A2",174 "poland": "A2",175 "portugal": "A2",176 "qatar": "A2",177 "reunion islands": "A2",178 "romania": "A2",179 "saudi arabia": "A2",180 "slovakia": "A2",181 "slovenia": "A2",182 "spain": "A2",183 "st. pierre and miquelon": "A2",184 "thailand": "A2",185 "united arab emirates": "A2",186 "uae": "A2",187 "uruguay": "A2",188 "vatican city": "A2",189 "wallis and futuna": "A2",190 191 # B1 -- Moderately Low Risk192 "albania": "B1",193 "algeria": "B1",194 "angola": "B1",195 "azerbaijan": "B1",196 "bahamas": "B1",197 "belize": "B1",198 "brunei": "B1",199 "cambodia": "B1",200 "chile": "B1",201 "china": "B1",202 "colombia": "B1",203 "cote divoire": "B1",204 "cuba": "B1",205 "dominican republic": "B1",206 "ecuador": "B1",207 "fiji": "B1",208 "georgia": "B1",209 "guatemala": "B1",210 "hong kong": "B1",211 "hungary": "B1",212 "jamaica": "B1",213 "kazakhstan": "B1",214 "macao": "B1",215 "nauru": "B1",216 "nepal": "B1",217 "paraguay": "B1",218 "peru": "B1",219 "st. christopher and nevis": "B1",220 "st. lucia": "B1",221 "south africa": "B1",222 "serbia": "B1",223 "seychelles": "B1",224 "timor leste": "B1",225 "trinidad and tobago": "B1",226 "vanuatu": "B1",227 "vietnam": "B1",228 229 # B2 -- Moderate Risk230 "armenia": "B2",231 "bangladesh": "B2",232 "barbados": "B2",233 "belarus": "B2",234 "benin": "B2",235 "bosnia and herzegovina": "B2",236 "cape verde": "B2",237 "costa rica": "B2",238 "greece": "B2",239 "honduras": "B2",240 "israel": "B2",241 "jordan": "B2",242 "kyrgyzstan": "B2",243 "madagascar": "B2",244 "montenegro": "B2",245 "morocco": "B2",246 "namibia": "B2",247 "nicaragua": "B2",248 "nigeria": "B2",249 "north macedonia": "B2",250 "panama": "B2",251 "rwanda": "B2",252 "senegal": "B2",253 "solomon islands": "B2",254 "st. vincent": "B2",255 "taiwan": "B2",256 "tanzania": "B2",257 "togo": "B2",258 "turkey": "B2",259 "turkmenistan": "B2",260 "uganda": "B2",261 "uzbekistan": "B2",262 263 # C1 -- Moderately High Risk264 "antigua and barbuda": "C1",265 "argentina": "C1",266 "bolivia": "C1",267 "comoros": "C1",268 "democratic republic of congo": "C1",269 "dominica": "C1",270 "egypt": "C1",271 "equatorial guinea": "C1",272 "gambia": "C1",273 "kenya": "C1",274 "kiribati": "C1",275 "lesotho": "C1",276 "liberia": "C1",277 "maldives": "C1",278 "moldova": "C1",279 "mongolia": "C1",280 "mauritania": "C1",281 "samoa": "C1",282 "tonga": "C1",283 284 # C2 -- High Risk285 "burkina faso": "C2",286 "cameroon": "C2",287 "chad": "C2",288 "djibouti": "C2",289 "eswatini": "C2",290 "gabon": "C2",291 "ghana": "C2",292 "guinea": "C2",293 "iran": "C2",294 "iraq": "C2",295 "laos": "C2",296 "libya": "C2",297 "marshall islands": "C2",298 "niger": "C2",299 "papua new guinea": "C2",300 "russia": "C2",301 "sierra leone": "C2",302 "syria": "C2",303 "tajikistan": "C2",304 "tuvalu": "C2",305 "ukraine": "C2",306 307 # D -- Very High Risk308 "afghanistan": "D",309 "burundi": "D",310 "central african republic": "D",311 "congo republic": "D",312 "el salvador": "D",313 "eritrea": "D",314 "ethiopia": "D",315 "guinea bissau": "D",316 "haiti": "D",317 "lebanon": "D",318 "malawi": "D",319 "mali": "D",320 "micronesia": "D",321 "mozambique": "D",322 "myanmar": "D",323 "north korea": "D",324 "pakistan": "D",325 "palestine": "D",326 "sao tome": "D",327 "somalia": "D",328 "south sudan": "D",329 "sri lanka": "D",330 "sudan": "D",331 "suriname": "D",332 "tunisia": "D",333 "venezuela": "D",334 "yemen": "D",335 "zambia": "D",336 "zimbabwe": "D",337 338 "other": "B2",339}340 341# Payment terms risk (Short Term -- up to 360 days)342# 361 days and above = Medium-Long Term343# 1460 days and above = Long Term344PAYMENT_TERMS_RISK = {345 (0, 30): 0,346 (31, 60): 5,347 (61, 90): 10,348 (91, 120): 15,349 (121, 360): 20,350}351 352LONG_TERM_PAYMENT_RISK = {353 (361, 1459): 20,354 (1460, 99999): 40,355}356 357# Base premium range (% of credit sales volume)358PREMIUM_RANGE = {359 "Standard": {"min": 1.00, "max": 1.75},360 "Enhanced": {"min": 1.75, "max": 2.50},361 "High Risk": {"min": 2.50, "max": 4.00},362 "Medium-Long Term": {"min": 3.75, "max": 6.00},363 "Declined": {"min": 0, "max": 0},364}365 366# Financial ratio scoring367# Each ratio: Low Risk = 5, Standard = 10, High Risk = 15368# Special: Negative TNW adds 75 directly -- auto decline369FINANCIAL_RATIO_SCORES = {370 "current_ratio": {"low": "> 2.0", "std": "1.0-2.0", "high": "< 1.0"},371 "tol_tnw": {"low": "< 1.5", "std": "1.5-3.0", "high": "> 3.0"},372 "bad_debt_pct": {"low": "< 1%", "std": "1%-3%", "high": "> 3%"},373 "tnw_pct_assets": {"low": "> 30%", "std": "10%-30%", "high": "< 10%", "negative": 75},374 "debtor_days": {"low": "< 45 days", "std": "45-90 days", "high": "> 90 days"},375 "creditor_days": {"low": "< 45 days", "std": "45-90 days", "high": "> 90 days"},376 "capital_adequacy":{"low": "> 30% TOL", "std": "15-30% TOL", "high": "< 15% TOL"},377}378 379# Final score weights380WEIGHTS = {381 "business_profile": 0.30,382 "financials": 0.40,383 "buyer_portfolio": 0.30,384}385 386# Buyer risk weights387BUYER_WEIGHTS = {388 "country_risk": 0.40,389 "industry_risk": 0.40,390 "customer_risk": 0.20,391}392 393 394# ============================================================395# SECTION 2 -- HELPER FUNCTIONS396# ============================================================397 398def get_payment_terms_score(days: int) -> tuple:399 """Returns risk score and payment term classification."""400 if days <= 360:401 for (min_d, max_d), score in PAYMENT_TERMS_RISK.items():402 if min_d <= days <= max_d:403 return score, "Short Term"404 return 20, "Short Term"405 elif days < 1460:406 return 20, "Medium-Long Term"407 else:408 return 40, "Long Term"409 410 411def get_country_risk_score(countries: list) -> tuple:412 """Returns highest country risk score and rating from buyer countries."""413 scores = []414 for country in countries:415 key = country.lower().strip()416 rating = COUNTRY_RISK.get(key, COUNTRY_RISK["other"])417 score = RATING_TO_SCORE.get(rating, 9)418 scores.append((score, rating, country))419 if not scores:420 return 9, "B2", "Unknown"421 scores.sort(reverse=True)422 return scores[0]423 424 425def get_concentration_score(top_buyer_pct: float) -> int:426 """Returns risk score based on buyer concentration."""427 if top_buyer_pct >= 75: return 25428 elif top_buyer_pct >= 50: return 15429 elif top_buyer_pct >= 30: return 10430 else: return 0431 432 433def get_loss_ratio_score(loss_ratio: float) -> int:434 """Returns risk score based on historical bad debt loss ratio."""435 if loss_ratio >= 0.05: return 25436 elif loss_ratio >= 0.03: return 15437 elif loss_ratio >= 0.01: return 10438 else: return 0439 440 441def get_premium_rate(tier: str, risk_score: int, term_classification: str = "Short Term") -> float:442 """Calculates premium rate based on tier, score and term classification."""443 if tier == "Declined":444 return 0.0445 if term_classification == "Medium-Long Term":446 r = PREMIUM_RANGE["Medium-Long Term"]447 position = risk_score / 100448 return round(r["min"] + (position * (r["max"] - r["min"])), 2)449 r = PREMIUM_RANGE.get(tier, PREMIUM_RANGE["High Risk"])450 tier_ranges = {"Standard": (0, 29), "Enhanced": (30, 49), "High Risk": (50, 74)}451 t_min, t_max = tier_ranges.get(tier, (0, 100))452 position = (risk_score - t_min) / max(t_max - t_min, 1)453 return round(r["min"] + (position * (r["max"] - r["min"])), 2)454 455 456# ============================================================457# SECTION 3 -- STREAM 1: BUSINESS PROFILE SCORING458# ============================================================459 460def score_business_profile(profile: dict) -> dict:461 """462 Stream 1 -- Scores the policyholder business profile.463 Returns raw score (0-100) and breakdown.464 """465 score = 0466 breakdown = {}467 industry_warnings = []468 469 # Industry risk470 industries = profile.get("industries", ["other"])471 if isinstance(industries, str):472 industries = [industries]473 474 ind_scores = []475 for ind in industries:476 ind_key = ind.lower().strip()477 ind_data = INDUSTRY_RISK.get(ind_key, INDUSTRY_RISK["other"])478 ind_scores.append(ind_data["score"])479 if ind_data["score"] >= ind_data["off_cover_limit"]:480 industry_warnings.append(481 f"{ind.title()} score ({ind_data['score']}) exceeds off-cover limit ({ind_data['off_cover_limit']})"482 )483 484 industry_score = max(ind_scores)485 score += industry_score486 breakdown["industry_risk"] = industry_score487 488 # Trade type risk489 trade_score = TRADE_TYPE_RISK.get(profile.get("trade_type", "domestic").lower(), 4)490 score += trade_score491 breakdown["trade_type_risk"] = trade_score492 493 # Country risk494 country_score, worst_rating, worst_country = get_country_risk_score(495 profile.get("buyer_countries", ["other"])496 )497 score += country_score498 breakdown["country_risk"] = country_score499 breakdown["worst_country"] = f"{worst_country} ({worst_rating} -> {country_score})"500 501 # Payment terms502 payment_score, term_class = get_payment_terms_score(503 profile.get("payment_terms_days", 30)504 )505 score += payment_score506 breakdown["payment_terms_risk"] = payment_score507 breakdown["payment_term_classification"] = term_class508 509 # Buyer concentration510 conc_score = get_concentration_score(profile.get("top_buyer_percentage", 0))511 score += conc_score512 breakdown["concentration_risk"] = conc_score513 514 # Loss ratio515 loss_score = get_loss_ratio_score(profile.get("loss_ratio", 0))516 score += loss_score517 breakdown["loss_ratio_risk"] = loss_score518 519 # Business maturity520 years = profile.get("years_in_business", 5)521 if years < 2: years_score = 15522 elif years < 5: years_score = 5523 else: years_score = 0524 score += years_score525 breakdown["business_maturity_risk"] = years_score526 527 return {528 "raw_score": min(score, 100),529 "breakdown": breakdown,530 "industry_warnings": industry_warnings,531 "term_classification": term_class,532 "worst_country": worst_country,533 "worst_rating": worst_rating,534 }535 536 537# ============================================================538# SECTION 4 -- STREAM 2: FINANCIAL RATIO SCORING539# ============================================================540 541def score_financial_ratios(financials: dict) -> dict:542 """543 Stream 2 -- Scores financial ratios extracted from uploaded544 financial statements by Nova Multimodal.545 Returns raw score (0-100) and breakdown.546 """547 score = 0548 breakdown = {}549 auto_decline = False550 auto_decline_reason = None551 552 rev = financials.get("annual_revenue", 1)553 cos = financials.get("cost_of_sales", rev * 0.6)554 ca = financials.get("current_assets", 0)555 cl = financials.get("current_liabilities", 1)556 tol = financials.get("total_liabilities", 0)557 tnw = financials.get("tangible_net_worth", 0)558 ta = financials.get("total_assets", 1)559 cap = financials.get("capital", 0)560 bd_val = financials.get("bad_debts", 0)561 debtors = financials.get("debtors", 0)562 creditors = financials.get("creditors", 0)563 564 # Current Ratio565 cr = ca / cl if cl > 0 else 0566 if cr > 2.0: cr_s, cr_c = 5, "Low Risk"567 elif cr >= 1.0: cr_s, cr_c = 10, "Standard"568 else: cr_s, cr_c = 15, "High Risk"569 score += cr_s570 breakdown["current_ratio"] = {"value": round(cr, 2), "category": cr_c, "score": cr_s}571 572 # TOL/TNW573 tt = tol / tnw if tnw > 0 else 999574 if tt < 1.5: tt_s, tt_c = 5, "Low Risk"575 elif tt <= 3.0: tt_s, tt_c = 10, "Standard"576 else: tt_s, tt_c = 15, "High Risk"577 score += tt_s578 breakdown["tol_tnw"] = {"value": round(tt, 2) if tt != 999 else "N/A", "category": tt_c, "score": tt_s}579 580 # Bad Debt %581 bd_pct = (bd_val / rev * 100) if rev > 0 else 0582 if bd_pct < 1.0: bd_s, bd_c = 5, "Low Risk"583 elif bd_pct <= 3.0: bd_s, bd_c = 10, "Standard"584 else: bd_s, bd_c = 15, "High Risk"585 score += bd_s586 breakdown["bad_debt_pct"] = {"value": f"{round(bd_pct, 2)}%", "category": bd_c, "score": bd_s}587 588 # TNW % of Total Assets589 if tnw < 0:590 auto_decline = True591 auto_decline_reason = "Negative Tangible Net Worth -- company is technically insolvent"592 score += 75593 breakdown["tnw_pct_assets"] = {594 "value": f"{tnw:,.2f}",595 "category": "NEGATIVE - Auto Decline",596 "score": 75597 }598 else:599 tnw_pct = (tnw / ta * 100) if ta > 0 else 0600 if tnw_pct > 30: tnw_s, tnw_c = 5, "Low Risk"601 elif tnw_pct >= 10: tnw_s, tnw_c = 10, "Standard"602 else: tnw_s, tnw_c = 15, "High Risk"603 score += tnw_s604 breakdown["tnw_pct_assets"] = {605 "value": f"{round(tnw_pct, 2)}%",606 "category": tnw_c,607 "score": tnw_s608 }609 610 # Debtor Days611 dd = (debtors / rev * 365) if rev > 0 else 0612 if dd < 45: dd_s, dd_c = 5, "Low Risk"613 elif dd <= 90: dd_s, dd_c = 10, "Standard"614 else: dd_s, dd_c = 15, "High Risk"615 score += dd_s616 breakdown["debtor_days"] = {"value": f"{round(dd, 1)} days", "category": dd_c, "score": dd_s}617 618 # Creditor Days619 cd = (creditors / cos * 365) if cos > 0 else 0620 if cd < 45: cd_s, cd_c = 5, "Low Risk"621 elif cd <= 90: cd_s, cd_c = 10, "Standard"622 else: cd_s, cd_c = 15, "High Risk"623 score += cd_s624 breakdown["creditor_days"] = {"value": f"{round(cd, 1)} days", "category": cd_c, "score": cd_s}625 626 # Capital Adequacy627 ca_pct = (cap / tol * 100) if tol > 0 else 100628 if ca_pct > 30: ca_s, ca_c = 5, "Low Risk"629 elif ca_pct >= 15: ca_s, ca_c = 10, "Standard"630 else: ca_s, ca_c = 15, "High Risk"631 score += ca_s632 breakdown["capital_adequacy"] = {"value": f"{round(ca_pct, 2)}%", "category": ca_c, "score": ca_s}633 634 return {635 "raw_score": min(score, 100),636 "breakdown": breakdown,637 "auto_decline": auto_decline,638 "auto_decline_reason": auto_decline_reason,639 }640 641 642# ============================================================643# SECTION 5 -- STREAM 3: BUYER PORTFOLIO SCORING644# ============================================================645 646def score_single_buyer(buyer: dict, customer_risk_score: int) -> dict:647 """648 Scores a single buyer using:649 Country risk (40%)650 Industry risk (40%)651 Customer score (20%)652 653 Args:654 buyer: dict with keys -- name, country, industry, exposure_amount655 customer_risk_score: business profile raw score656 657 Returns:658 dict with buyer risk score and breakdown659 """660 country = buyer.get("country", "other").lower().strip()661 rating = COUNTRY_RISK.get(country, COUNTRY_RISK["other"])662 country_s = RATING_TO_SCORE.get(rating, 9)663 664 industry = buyer.get("industry", "other").lower().strip()665 ind_s = INDUSTRY_RISK.get(industry, INDUSTRY_RISK["other"])["score"]666 667 buyer_score = (668 (country_s * BUYER_WEIGHTS["country_risk"]) +669 (ind_s * BUYER_WEIGHTS["industry_risk"]) +670 (customer_risk_score * BUYER_WEIGHTS["customer_risk"])671 )672 673 return {674 "buyer_name": buyer.get("name"),675 "country": buyer.get("country"),676 "industry": industry,677 "risk_score": round(buyer_score, 2),678 "exposure": buyer.get("exposure_amount", 0),679 "breakdown": {680 "country_risk": f"{country} ({rating}) -> {country_s} x 40% = {round(country_s * 0.40, 2)}",681 "industry_risk": f"{industry} -> {ind_s} x 40% = {round(ind_s * 0.40, 2)}",682 "customer_risk": f"customer score {customer_risk_score} x 20% = {round(customer_risk_score * 0.20, 2)}",683 }684 }685 686 687def score_buyer_portfolio(buyers: list, customer_risk_score: int) -> dict:688 """689 Scores all buyers and combines into weighted average690 floored to nearest whole number using math.floor().691 692 Args:693 buyers: list of buyer dicts with exposure_amount694 customer_risk_score: business profile raw score695 696 Returns:697 dict with portfolio score and individual scores698 """699 if not buyers:700 return {"portfolio_score": 0, "buyer_scores": [], "total_exposure": 0}701 702 total_exposure = sum(b.get("exposure_amount", 0) for b in buyers)703 buyer_scores = [score_single_buyer(b, customer_risk_score) for b in buyers]704 705 if total_exposure > 0:706 weighted_sum = sum(b["risk_score"] * b["exposure"] for b in buyer_scores)707 portfolio_score = math.floor(weighted_sum / total_exposure)708 else:709 portfolio_score = math.floor(sum(b["risk_score"] for b in buyer_scores) / len(buyer_scores))710 711 return {712 "portfolio_score": portfolio_score,713 "buyer_scores": buyer_scores,714 "total_exposure": total_exposure,715 }716 717 718# ============================================================719# SECTION 6 -- MAIN SCORING FUNCTION720# ============================================================721 722def calculate_risk_score(profile: dict) -> dict:723 """724 Calculates final weighted trade credit insurance risk score.725 726 Final Score = (Business Profile x 0.30)727 + (Financial Ratios x 0.40)728 + (Buyer Portfolio x 0.30)729 730 Args:731 profile: dict with keys:732 business_name (str)733 industries (list)734 trade_type (str) -- export / domestic / both735 annual_turnover (float)736 credit_sales_percentage(float)737 buyer_countries (list)738 top_buyer_percentage (float)739 payment_terms_days (int)740 loss_ratio (float)741 years_in_business (int)742 buyers (list) -- name, country, industry, exposure_amount743 financials (dict) -- extracted by Nova Multimodal744 745 Returns:746 dict with final score, tier, premium, and full breakdown747 """748 749 # Stream 1 -- Business Profile750 bp = score_business_profile(profile)751 bp_score = bp["raw_score"]752 753 # Stream 2 -- Financial Ratios754 financials = profile.get("financials")755 if financials:756 fin = score_financial_ratios(financials)757 fin_score = fin["raw_score"]758 auto_decline = fin["auto_decline"]759 auto_decline_reason = fin["auto_decline_reason"]760 else:761 fin = {"raw_score": 50, "breakdown": {}, "auto_decline": False, "auto_decline_reason": None}762 fin_score = 50763 auto_decline = False764 auto_decline_reason = None765 766 # Stream 3 -- Buyer Portfolio767 buyer_result = score_buyer_portfolio(profile.get("buyers", []), bp_score)768 buyer_score = buyer_result["portfolio_score"]769 770 # Weighted Final Score771 weighted_score = (772 (bp_score * WEIGHTS["business_profile"]) +773 (fin_score * WEIGHTS["financials"]) +774 (buyer_score * WEIGHTS["buyer_portfolio"])775 )776 final_score = math.floor(min(weighted_score, 100))777 term_class = bp.get("term_classification", "Short Term")778 779 # Risk Tier780 if auto_decline:781 tier = "Declined"782 tier_description = f"Auto decline -- {auto_decline_reason}"783 elif final_score >= 75:784 tier = "Declined"785 tier_description = "Total risk score exceeds maximum threshold"786 elif bp["industry_warnings"]:787 tier = "Declined"788 tier_description = f"Industry off-cover limit breached -- {'; '.join(bp['industry_warnings'])}"789 elif final_score < 30:790 tier = "Standard"791 tier_description = "Low risk -- eligible for full coverage at standard rates"792 elif final_score < 50:793 tier = "Enhanced"794 tier_description = "Moderate risk -- eligible for coverage with standard conditions"795 else:796 tier = "High Risk"797 tier_description = "Elevated risk -- coverage available with restricted terms"798 799 # Premium Calculation800 premium_rate = get_premium_rate(tier, final_score, term_class)801 credit_sales = profile.get("annual_turnover", 0) * (profile.get("credit_sales_percentage", 100) / 100)802 annual_premium = round(credit_sales * (premium_rate / 100), 2)803 804 return {805 "business_name": profile.get("business_name", "Unknown"),806 "final_score": final_score,807 "risk_tier": tier,808 "tier_description": tier_description,809 "premium_rate": f"{premium_rate}% of credit sales",810 "annual_premium": f"{annual_premium:,.2f}",811 "credit_sales_volume": f"{credit_sales:,.2f}",812 "score_breakdown": {813 "business_profile": {814 "raw_score": bp_score,815 "weighted_score": round(bp_score * WEIGHTS["business_profile"], 2),816 "detail": bp["breakdown"],817 },818 "financial_ratios": {819 "raw_score": fin_score,820 "weighted_score": round(fin_score * WEIGHTS["financials"], 2),821 "detail": fin["breakdown"],822 },823 "buyer_portfolio": {824 "raw_score": buyer_score,825 "weighted_score": round(buyer_score * WEIGHTS["buyer_portfolio"], 2),826 "buyers": buyer_result["buyer_scores"],827 },828 },829 "industry_warnings": bp["industry_warnings"],830 }831 832 833# ============================================================834# SECTION 7 -- TEST835# ============================================================836 837if __name__ == "__main__":838 839 import time840 841 # Test 1 -- Healthy UK manufacturer with financials and buyers842 profile_1 = {843 "business_name": "SoundFinance Ltd",844 "industries": ["manufacturing"],845 "trade_type": "both",846 "annual_turnover": 4000000,847 "credit_sales_percentage": 75,848 "buyer_countries": ["United Kingdom", "Germany"],849 "top_buyer_percentage": 25,850 "payment_terms_days": 45,851 "loss_ratio": 0.01,852 "years_in_business": 7,853 "buyers": [854 {"name": "BuyerA UK", "country": "United Kingdom", "industry": "retail", "exposure_amount": 200000},855 {"name": "BuyerB Germany", "country": "Germany", "industry": "manufacturing", "exposure_amount": 150000},856 ],857 "financials": {858 "annual_revenue": 4000000,859 "current_assets": 800000,860 "current_liabilities": 350000,861 "total_liabilities": 1200000,862 "tangible_net_worth": 600000,863 "total_assets": 1800000,864 "capital": 400000,865 "bad_debts": 32000,866 "debtors": 450000,867 "creditors": 280000,868 "cost_of_sales": 2400000,869 }870 }871 872 # Test 2 -- High risk exporter with negative TNW873 profile_2 = {874 "business_name": "HighRisk Traders Ltd",875 "industries": ["construction"],876 "trade_type": "export",877 "annual_turnover": 2000000,878 "credit_sales_percentage": 90,879 "buyer_countries": ["Venezuela", "Lebanon"],880 "top_buyer_percentage": 80,881 "payment_terms_days": 120,882 "loss_ratio": 0.06,883 "years_in_business": 1,884 "buyers": [885 {"name": "Buyer Venezuela", "country": "Venezuela", "industry": "construction", "exposure_amount": 300000},886 {"name": "Buyer Lebanon", "country": "Lebanon", "industry": "retail", "exposure_amount": 200000},887 ],888 "financials": {889 "annual_revenue": 2000000,890 "current_assets": 200000,891 "current_liabilities": 500000,892 "total_liabilities": 1500000,893 "tangible_net_worth": -200000,894 "total_assets": 1300000,895 "capital": 50000,896 "bad_debts": 80000,897 "debtors": 300000,898 "creditors": 450000,899 "cost_of_sales": 1600000,900 }901 }902 903 for test_profile in [profile_1, profile_2]:904 result = calculate_risk_score(test_profile)905 logging.info("=" * 60)906 logging.info(f"Business : {result['business_name']}")907 logging.info(f"Final Score : {result['final_score']} / 100")908 logging.info(f"Risk Tier : {result['risk_tier']}")909 logging.info(f"Description : {result['tier_description']}")910 logging.info(f"Premium Rate : {result['premium_rate']}")911 logging.info(f"Annual Premium : {result['annual_premium']}")912 bd_result = result["score_breakdown"]913 logging.info(f"Business Profile : raw={bd_result['business_profile']['raw_score']} weighted={bd_result['business_profile']['weighted_score']}")914 logging.info(f"Financial Ratios : raw={bd_result['financial_ratios']['raw_score']} weighted={bd_result['financial_ratios']['weighted_score']}")915 logging.info(f"Buyer Portfolio : raw={bd_result['buyer_portfolio']['raw_score']} weighted={bd_result['buyer_portfolio']['weighted_score']}")916 for buyer_item in bd_result["buyer_portfolio"]["buyers"]:917 logging.info(f" {buyer_item['buyer_name']:<25} Score: {buyer_item['risk_score']}")918 if result["industry_warnings"]:919 for warning in result["industry_warnings"]:920 logging.warning(f"WARNING: {warning}")921 922 # Measure underwriting engine speed (average of 1000 runs)923 runs = 1000924 start = time.time()925 for _ in range(runs):926 calculate_risk_score(profile_1)927 elapsed = (time.time() - start) / runs * 1000928 logging.info(f"Underwriting time (avg over {runs} runs): {elapsed:.3f} ms")