"""data_fetchers/owner_classifier.py — Clasifica owner_name del PA en tipos. Estrategia de negocio: cuando el owner es un BANCO (BoA, Wells Fargo, etc.), hay oportunidad de REO direct outreach — comprar por debajo de market. USAGE: from data_fetchers.owner_classifier import classify_owner, build_reo_signal classification = classify_owner("BANK OF AMERICA NA TRSTEE") # → {type, category, confidence, normalized, evidence} reo = build_reo_signal( owner_name="BANK OF AMERICA NA TRSTEE", just_value=322580, assessed_value=228560, taxes_paid_last=5256.59, ) # → {is_reo_opportunity, suggested_offer_range, justification, ...} """ from __future__ import annotations import re from typing import Optional # ════════════════════════════════════════════════════════════════════════════ # Owner-type patterns (priority order — first match wins) # ════════════════════════════════════════════════════════════════════════════ LENDER_PATTERNS = { # Big national banks "BANK_NATIONAL": [ "BANK OF AMERICA", "WELLS FARGO", "JPMORGAN", "CHASE BANK", "CHASE MORTGAGE", "U.S. BANK", "US BANK", "USBANK", "PNC BANK", "TRUIST", "CITIBANK", "CITIMORTGAGE", "REGIONS BANK", "FIFTH THIRD", "KEY BANK", "HUNTINGTON NATIONAL", "SUNTRUST", "BMO HARRIS", "TD BANK", ], # GSE / Federal agencies (foreclosure → these guys hold inventory) "GSE_FEDERAL": [ "FEDERAL HOME LOAN MORTGAGE", "FREDDIE MAC", "FREDDIEMAC", "FEDERAL NATIONAL MORTGAGE", "FANNIE MAE", "FANNIEMAE", "SECRETARY OF HUD", "SECRETARY OF HOUSING", "U.S. DEPARTMENT OF HOUSING", "VA SECRETARY", "VETERANS AFFAIRS", "SECRETARY OF VETERANS AFFAIRS", ], # MBS / Trustee banks (Mortgage-Backed Securities trustees) "MBS_TRUSTEE": [ "DEUTSCHE BANK NATIONAL TRUST", "BANK OF NEW YORK MELLON", "U.S. BANK TRUST", "U.S. BANK NATIONAL ASSOCIATION TRUSTEE", "WILMINGTON SAVINGS", "WILMINGTON TRUST", " AS TRUSTEE FOR", "NA TRSTEE", "TRSTEE", "CHRISTIANA TRUST", "WELLS FARGO BANK NA TRSTEE", "HSBC BANK USA NATIONAL", "HSBC BANK USA", ], # Loan servicers (often own REO too) "SERVICER": [ "BAYVIEW LOAN SERVICING", "SHELLPOINT MORTGAGE", "NEWREZ", "MR. COOPER", "MR COOPER", "SPECIALIZED LOAN SERVICING", "PHH MORTGAGE", "OCWEN", "SELENE FINANCE", "RUSHMORE LOAN", "FAY SERVICING", "CARRINGTON MORTGAGE", "PENNYMAC", "FREEDOM MORTGAGE", ], # Fintech lenders "FINTECH_LENDER": [ "ROCKET MORTGAGE", "QUICKEN LOANS", "BETTER MORTGAGE", "LOANDEPOT", "GUILD MORTGAGE", "AMERIHOME", ], # Community banks / regional (FL-specific common) "BANK_REGIONAL": [ "SEACOAST BANK", "VALLEY NATIONAL", "FIRST HORIZON", "CENTENNIAL BANK", "BANKUNITED", "AMERIS BANK", "SYNOVUS", "ATLANTIC CAPITAL", "PROFESSIONAL BANK", ], # Tax certificate holders (acquired via tax deed) "TAX_CERTIFICATE_HOLDER": [ "TAX CERTIFICATE", "TAX DEED HOLDER", "FLORIDA TAX CERTIFICATE", ], # Insurance / pension (sometimes own real estate) "INSURANCE_PENSION": [ "STATE FARM", "ALLSTATE", "PRUDENTIAL", "MASS MUTUAL", "METLIFE", ], } # Government entities (NOT REO opportunities, usually held for public use) GOVERNMENT_PATTERNS = [ "STATE OF FLORIDA", "FLORIDA DEPT", "FLORIDA DEPARTMENT", "CITY OF", "COUNTY OF", "TOWN OF", "VILLAGE OF", "SCHOOL BOARD", "SCHOOL DISTRICT", "MUNICIPALITY OF", "UNITED STATES OF AMERICA", "U.S. POSTAL", "U.S. ARMY", "FLORIDA POWER", "FPL ", "WATER MANAGEMENT DISTRICT", "DEPARTMENT OF TRANSPORTATION", "DOT ", ] # Non-profit / religious (rare REO scenarios) NONPROFIT_PATTERNS = [ "CHURCH OF", "CATHOLIC", "BAPTIST", "METHODIST", "FOUNDATION", "MINISTRIES", "RELIGIOUS", "HABITAT FOR HUMANITY", "REDEVELOPMENT", "NON-PROFIT", "NONPROFIT", "RED CROSS", ] # LLC patterns (investor-owned, possible negotiation) LLC_PATTERNS = [ " LLC", "LLC ", "L.L.C.", "L.L.C ", " INC", " INC.", "INCORPORATED", " CORP", "CORPORATION", " LTD", " LP", "LIMITED PARTNERSHIP", ] # Trust patterns (family trust vs MBS trust — context matters) TRUST_PATTERNS = [ " TRUST", "TRUSTEE", " TR ", "LIVING TRUST", "FAMILY TRUST", "REVOCABLE TRUST", "IRREVOCABLE TRUST", ] # Individual indicators (LE = Life Estate, REM = Remainderman, etc.) INDIVIDUAL_INDICATORS = [ " LE", " REM", " H/W", "HUSBAND", "WIFE", "& W ", " &W ", "&H ", " EST", "ESTATE OF", ] # ════════════════════════════════════════════════════════════════════════════ # Public API # ════════════════════════════════════════════════════════════════════════════ def classify_owner(owner_name: Optional[str], co_owners: Optional[list[str]] = None) -> dict: """Classify owner_name into business categories. Args: owner_name: primary owner name from PA co_owners: optional list of additional owners Returns: { "type": str (category code), "category": str (business-level), "is_lender": bool, "is_government": bool, "is_individual": bool, "is_investor_entity": bool, "is_trust": bool, "confidence": float (0-1), "matched_keyword": str | None, "normalized": str (uppercase, stripped), "evidence": [str], } """ out = { "type": "UNKNOWN", "category": "unknown", "is_lender": False, "is_government": False, "is_individual": False, "is_investor_entity": False, "is_trust": False, "confidence": 0.0, "matched_keyword": None, "normalized": "", "evidence": [], } if not owner_name: return out # Combine owner + co-owners for full classification full_text = owner_name.upper() if co_owners: full_text += " | " + " | ".join((c or "").upper() for c in co_owners if c) out["normalized"] = full_text # 1. Check lender categories (highest priority — REO opportunity) for category, patterns in LENDER_PATTERNS.items(): for pat in patterns: if pat.upper() in full_text: out["type"] = category out["category"] = "lender" out["is_lender"] = True out["matched_keyword"] = pat out["confidence"] = 0.95 out["evidence"].append(f"matched lender keyword '{pat}'") return out # 2. Government entities (not REO, but flagged) for pat in GOVERNMENT_PATTERNS: if pat.upper() in full_text: out["type"] = "GOVERNMENT" out["category"] = "government" out["is_government"] = True out["matched_keyword"] = pat out["confidence"] = 0.90 out["evidence"].append(f"matched government keyword '{pat}'") return out # 3. Non-profit for pat in NONPROFIT_PATTERNS: if pat.upper() in full_text: out["type"] = "NONPROFIT" out["category"] = "nonprofit" out["matched_keyword"] = pat out["confidence"] = 0.85 out["evidence"].append(f"matched nonprofit keyword '{pat}'") return out # 4. Trust (family trust vs MBS trust — usually family if not caught by MBS_TRUSTEE above) is_trust = any(pat in full_text for pat in TRUST_PATTERNS) if is_trust: out["is_trust"] = True out["type"] = "FAMILY_TRUST" out["category"] = "trust" out["matched_keyword"] = next((p for p in TRUST_PATTERNS if p in full_text), None) out["confidence"] = 0.80 out["evidence"].append("Trust keyword detected (likely family/estate trust)") # Don't return — might also be LLC # 5. LLC / Corporation is_llc = any(pat in full_text for pat in LLC_PATTERNS) if is_llc: out["is_investor_entity"] = True if out["type"] == "UNKNOWN": out["type"] = "LLC_OR_CORP" out["category"] = "investor_entity" out["matched_keyword"] = next((p for p in LLC_PATTERNS if p in full_text), None) out["confidence"] = 0.85 out["evidence"].append(f"matched LLC/corp keyword") return out if is_trust: return out # 6. Individual heuristic — owner name has comma (LASTNAME, FIRSTNAME format) # OR contains individual indicators has_comma = "," in full_text has_individual = any(ind in full_text for ind in INDIVIDUAL_INDICATORS) # OR has only 2-4 words and no numbers words = full_text.replace(",", " ").split() word_count = len(words) has_numbers = any(any(c.isdigit() for c in w) for w in words) if has_comma or has_individual or (2 <= word_count <= 5 and not has_numbers): out["type"] = "INDIVIDUAL" out["category"] = "individual" out["is_individual"] = True out["confidence"] = 0.70 out["evidence"].append("Name pattern matches individual (comma format or 2-5 words)") return out # Default: unknown out["type"] = "UNKNOWN" out["category"] = "unknown" out["confidence"] = 0.10 out["evidence"].append("No clear pattern matched") return out def build_reo_signal( *, owner_classification: dict, just_value: Optional[float] = None, assessed_value: Optional[float] = None, listing_price: Optional[float] = None, taxes_paid_last: Optional[float] = None, mailing_address: Optional[str] = None, ) -> dict: """Build REO direct outreach opportunity signal. Para owners que son lender (BANK_NATIONAL, GSE_FEDERAL, MBS_TRUSTEE): sugiere oferta directa para evitar MLS + comisiones del agent del banco. Returns: { "is_reo_opportunity": bool, "lender_type": str | None, "strategy": str | None, "suggested_offer_low": int | None, "suggested_offer_high": int | None, "discount_pct_vs_market": float | None, "justification_es": str | None, "outreach_contact_hint": str | None, } """ out = { "is_reo_opportunity": False, "lender_type": None, "strategy": None, "suggested_offer_low": None, "suggested_offer_high": None, "discount_pct_vs_market": None, "justification_es": None, "outreach_contact_hint": None, } if not owner_classification.get("is_lender"): return out lender_type = owner_classification.get("type") out["lender_type"] = lender_type out["is_reo_opportunity"] = True # Math: suggest 85-95% of assessed value as offer range (banks accept this to liquidate) base = assessed_value or just_value or listing_price or 0 if base > 0: offer_low = int(base * 0.85) offer_high = int(base * 0.92) out["suggested_offer_low"] = offer_low out["suggested_offer_high"] = offer_high market_ref = just_value or listing_price or base if market_ref > 0: mid = (offer_low + offer_high) / 2 out["discount_pct_vs_market"] = round((1 - mid / market_ref) * 100, 1) # Strategy + justification by lender type if lender_type == "BANK_NATIONAL": out["strategy"] = "Direct REO desk outreach" out["justification_es"] = ( f"Banco nacional como owner = REO post-foreclosure en su balance. " f"REO desks tienen quota mensual de disposicion. Aceptan ofertas direct " f"para evitar MLS + 6% commission. " f"Ofrece ${offer_low:,}-${offer_high:,} ({out.get('discount_pct_vs_market', 0)}% bajo market). " f"Si cubris taxes pendientes (~${taxes_paid_last or 5000:,.0f}/año) y cerras cash o " f"conventional ready, alta probabilidad de aceptacion." ) out["outreach_contact_hint"] = ( "Contact: search '{bank} REO disposition' o 'REO asset manager' en LinkedIn. " "Mail oficial: {mailing_address} (REO department typically receives correspondence here)." ).format( bank=lender_type.replace("_", " ").title(), mailing_address=mailing_address or "ver PA record", ) elif lender_type == "GSE_FEDERAL": out["strategy"] = "Fannie/Freddie HomePath / HUD HomeStore" out["justification_es"] = ( f"GSE (Fannie/Freddie/HUD) como owner. Suelen vender via canales " f"oficiales: HomePath.com (Fannie), HomeSteps.com (Freddie), HUDHomeStore.gov. " f"Periodo Owner-Occupied first ~15-30 dias, despues investors. " f"Si la propiedad lleva >30 dias unsold, oferta bajo asking es aceptable. " f"Sugerido ${offer_low:,}-${offer_high:,}." ) out["outreach_contact_hint"] = ( "Buscar la propiedad en HomePath.com (Fannie) / HomeSteps.com (Freddie) / " "HUDHomeStore.gov para ver listing oficial + plazos." ) elif lender_type == "MBS_TRUSTEE": out["strategy"] = "Trustee-held REO (MBS securitization)" out["justification_es"] = ( f"Trustee bank de un MBS (mortgage-backed security). Propiedad fue " f"foreclosed y entro al inventory del trust. El trustee delega a un " f"servicer (BAYVIEW/SHELLPOINT/etc) para la liquidacion. " f"Mas burocratico que un REO de bank-direct pero similar dinamica. " f"Oferta sugerida ${offer_low:,}-${offer_high:,}." ) out["outreach_contact_hint"] = ( "Identificar el servicer (suele estar en el mailing address o documento de transferencia). " "Contactar al servicer's REO/loss mitigation department." ) elif lender_type == "SERVICER": out["strategy"] = "Servicer-held REO outreach" out["justification_es"] = ( f"Loan servicer como owner = post-foreclosure inventory. Servicers tienen " f"presion por compliance + balance sheet para liquidar. " f"Oferta sugerida ${offer_low:,}-${offer_high:,}." ) out["outreach_contact_hint"] = "Contact servicer's REO disposition department." return out # ════════════════════════════════════════════════════════════════════════════ # CLI # ════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": import argparse import json parser = argparse.ArgumentParser(description="Owner classifier + REO signal") parser.add_argument("owner_name", help="Owner name from PA (e.g. 'BANK OF AMERICA NA TRSTEE')") parser.add_argument("--co-owners", nargs="*", help="Additional co-owners") parser.add_argument("--just-value", type=float, help="Just/market value") parser.add_argument("--assessed-value", type=float, help="Assessed value") parser.add_argument("--listing-price", type=float, help="Current listing price") parser.add_argument("--taxes-paid", type=float, help="Taxes paid last year") parser.add_argument("--mailing", help="Mailing address (for REO contact hint)") args = parser.parse_args() cls = classify_owner(args.owner_name, co_owners=args.co_owners) print("=== CLASSIFICATION ===") print(json.dumps(cls, indent=2, default=str)) reo = build_reo_signal( owner_classification=cls, just_value=args.just_value, assessed_value=args.assessed_value, listing_price=args.listing_price, taxes_paid_last=args.taxes_paid, mailing_address=args.mailing, ) print("\n=== REO SIGNAL ===") print(json.dumps(reo, indent=2, default=str))