Files
2026-07-03 12:24:58 -04:00

400 lines
16 KiB
Python

"""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))