CoolFace
Apppublic

PABPAT/TCI_Shield

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
underwriting_engine.py928 linesDownload Raw Back to core
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")