400 lines
16 KiB
Python
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))
|