1500 lines
68 KiB
Python
1500 lines
68 KiB
Python
"""finance_calculator.py - Calculos financieros EXACTOS en Python.
|
|
|
|
PROBLEMA QUE RESUELVE:
|
|
El LLM (DealAnalyzer en Ollama) se confunde con aritmetica simple. En el smoke
|
|
test del fix #2 calculo MAB=$114K vs starting bid $120K y dijo "PASS" cuando la
|
|
regla NO BID debia activarse (starting > MAB*0.95). El razonamiento conceptual
|
|
fue correcto, la comparacion numerica fallo.
|
|
|
|
SOLUCION:
|
|
Computar TODOS los numeros en Python antes de llamar al DealAnalyzer. Pasarle
|
|
los resultados como "DATOS CALCULADOS - usalos exactos, NO recalcules" en el
|
|
prompt. El LLM solo interpreta y razona sobre la estrategia ganadora.
|
|
|
|
FUNCIONES IMPLEMENTADAS:
|
|
- calculate_piti(loan_amount, rate, years, tax_annual, insurance_annual, hoa_monthly)
|
|
- calculate_dscr(noi_annual, piti_annual)
|
|
- calculate_cash_flow(rent_monthly, piti_monthly, vacancy_pct, mgmt_pct, maint_pct, capex_pct)
|
|
- calculate_cap_rate(noi_annual, purchase_price)
|
|
- calculate_coc_return(annual_cash_flow, total_cash_invested)
|
|
- calculate_buy_and_hold(deal_inputs) # combinacion de todo lo de arriba
|
|
- calculate_brrrr_metrics(price, rehab, arv, refi_ltv)
|
|
- calculate_mao_wholesale(arv, rehab)
|
|
- calculate_section8_rent(market_rent, fmr)
|
|
- calculate_section8_scenario(deal_inputs, fmr)
|
|
- calculate_mab_auction(arv, rehab, title_reserve)
|
|
- calculate_auction_scenario(deal_inputs, starting_bid)
|
|
- compute_all_scenarios(deal_inputs, verified_data, deal_type) # entry point
|
|
|
|
Todas las funciones devuelven dicts con valores numericos exactos (no LLM-friendly text).
|
|
build_calculated_block() formatea los resultados como bloque markdown para inyectar
|
|
en el prompt del DealAnalyzer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, Literal
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Constantes y benchmarks de Florida
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
# Reservas operativas como % de la renta (estandar de inversion)
|
|
VACANCY_PCT = 0.08 # 8% vacancia
|
|
MGMT_PCT = 0.10 # 10% property management
|
|
MAINT_PCT = 0.05 # 5% mantenimiento
|
|
CAPEX_PCT = 0.05 # 5% CapEx reserve
|
|
|
|
# Loan defaults — INVESTOR FINANCING
|
|
DSCR_LOAN_RATE = 0.0775 # 7.75% tasa DSCR loan
|
|
DSCR_LOAN_LTV = 0.75 # 75% down payment requires 25%
|
|
CONVENTIONAL_LTV = 0.75 # 75% investment property
|
|
HARD_MONEY_RATE = 0.12 # 12% interest
|
|
HARD_MONEY_POINTS = 0.02 # 2 points origination
|
|
HARD_MONEY_TERM_MONTHS = 6 # 6 meses tipico
|
|
BRRRR_REFI_LTV = 0.75 # 75% LTV post-refi
|
|
BRRRR_REFI_RATE = 0.075 # 7.5% post-refi rate
|
|
|
|
# Loan defaults — OWNER-OCCUPIED (vivir en la casa)
|
|
# Bug fix 2026-05-15: usuario reporto que system solo mostraba 12% (hard money).
|
|
# Para alguien que compra para VIVIR (primary residence) las tasas reales son:
|
|
FHA_RATE = 0.0625 # 6.25% FHA loan (~30y fixed, owner-occupied)
|
|
FHA_DOWN_PCT = 0.035 # 3.5% minimum down (FHA)
|
|
FHA_MIP_RATE = 0.0055 # 0.55% annual MIP (mortgage insurance premium)
|
|
CONVENTIONAL_OO_RATE = 0.065 # 6.5% conventional owner-occupied (~30y)
|
|
CONVENTIONAL_OO_DOWN_PCT = 0.05 # 5% min conv OO (PMI < 20% down)
|
|
CONVENTIONAL_OO_PMI_RATE = 0.005 # 0.5% annual PMI if < 20% down
|
|
VA_RATE = 0.060 # 6.00% VA loan (military, 0% down)
|
|
|
|
# DTI thresholds (debt-to-income ratios)
|
|
# Front-end = mortgage PITI / gross monthly income
|
|
# Back-end = total debt / gross monthly income
|
|
DTI_FRONT_END_CONVENTIONAL = 0.28 # 28% conventional
|
|
DTI_FRONT_END_FHA = 0.31 # 31% FHA
|
|
DTI_BACK_END_CONVENTIONAL = 0.36 # 36% conventional
|
|
DTI_BACK_END_FHA = 0.43 # 43% FHA
|
|
DTI_FRONT_END_AGGRESSIVE_MAX = 0.35 # >35% es claramente riesgoso
|
|
DTI_FRONT_END_DANGER_ZONE = 0.40 # >40% no califica typically
|
|
|
|
# Target margin para "max profitable offer"
|
|
TARGET_INVESTOR_MARGIN_PCT = 0.20 # 20% margin sobre ARV - costos
|
|
TARGET_LIVE_IN_MARGIN_PCT = 0.05 # 5% margin para owner-occupant (no es flip)
|
|
|
|
# Wholesale rule
|
|
WHOLESALE_ARV_PCT = 0.70 # MAO = ARV*0.70 - rehab (regla 70)
|
|
WHOLESALE_MIN_SPREAD = 10_000 # PASS si spread > $10K
|
|
|
|
# Section 8 multiplier (paga 100-110% de FMR tipicamente)
|
|
SECTION8_FMR_MULTIPLIER = 1.05 # asumimos 105% del FMR para calculos
|
|
|
|
# Auction (Fix #2)
|
|
AUCTION_ARV_FLOOR = 0.65 # MAB usa 65% del ARV (mas conservador que 70 de wholesale)
|
|
AUCTION_REHAB_WORST_CASE = 1.5 # factor 1.5x sin inspeccion previa
|
|
AUCTION_BUFFER_PCT = 0.10 # 10% buffer en MAB
|
|
TITLE_RESERVE_MIAMI_DADE = 15_000 # title issues mas comunes en Miami-Dade
|
|
TITLE_RESERVE_OTHER = 5_000
|
|
|
|
# Auction veredict thresholds (sobre MAB)
|
|
AUCTION_PASS_THRESHOLD = 0.70 # starting < MAB*0.70 = PASS
|
|
AUCTION_CAUTION_THRESHOLD = 0.95 # starting < MAB*0.95 = CAUTION; sobre = NO BID
|
|
|
|
# Buy & Hold pass thresholds
|
|
BUYHOLD_DSCR_MIN = 1.20
|
|
BUYHOLD_CASHFLOW_MIN = 200 # $/mo
|
|
SECTION8_DSCR_MIN = 1.25
|
|
BRRRR_TRAPPED_CAPITAL_PCT_MAX = 0.25 # 25% del invested original
|
|
|
|
# Florida insurance defaults por price tier (anual)
|
|
FL_INSURANCE_TIERS = [
|
|
(300_000, 4_000), # < $300K -> $4K/year
|
|
(500_000, 6_000), # < $500K -> $6K/year
|
|
(float("inf"), 8_000), # >= $500K -> $8K/year
|
|
]
|
|
FLOOD_INSURANCE_AE = 3_000 # ~$2-4K typical
|
|
FLOOD_INSURANCE_VE = 10_000 # ~$8-15K typical
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Calculo primario: PITI mensual
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_monthly_mortgage_payment(loan_amount: float, annual_rate: float, years: int) -> float:
|
|
"""Calcula el pago mensual P+I de una hipoteca standard.
|
|
|
|
Formula: M = P * [r(1+r)^n] / [(1+r)^n - 1]
|
|
donde r = tasa mensual, n = meses totales.
|
|
"""
|
|
if loan_amount <= 0:
|
|
return 0.0
|
|
if annual_rate <= 0:
|
|
return loan_amount / (years * 12) # interest-free, divide en cuotas
|
|
r = annual_rate / 12.0
|
|
n = years * 12
|
|
if r == 0:
|
|
return loan_amount / n
|
|
numerator = r * (1 + r) ** n
|
|
denominator = (1 + r) ** n - 1
|
|
return loan_amount * numerator / denominator
|
|
|
|
|
|
def calculate_piti(
|
|
loan_amount: float,
|
|
annual_rate: float,
|
|
years: int,
|
|
tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
) -> dict:
|
|
"""PITI = Principal + Interest + Tax + Insurance + HOA.
|
|
|
|
Returns dict con piti_monthly (total), p_and_i_monthly, t_and_i_monthly, hoa_monthly.
|
|
"""
|
|
p_and_i = calculate_monthly_mortgage_payment(loan_amount, annual_rate, years)
|
|
t_and_i = (tax_annual + insurance_annual) / 12.0
|
|
total = p_and_i + t_and_i + hoa_monthly
|
|
return {
|
|
"piti_monthly": round(total, 2),
|
|
"piti_annual": round(total * 12, 2),
|
|
"p_and_i_monthly": round(p_and_i, 2),
|
|
"t_and_i_monthly": round(t_and_i, 2),
|
|
"hoa_monthly": round(hoa_monthly, 2),
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# NOI / DSCR / Cash Flow / Cap Rate / CoC
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_operating_expenses(
|
|
rent_monthly: float,
|
|
tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
vacancy_pct: float = VACANCY_PCT,
|
|
mgmt_pct: float = MGMT_PCT,
|
|
maint_pct: float = MAINT_PCT,
|
|
capex_pct: float = CAPEX_PCT,
|
|
) -> dict:
|
|
"""Total operating expenses anuales (PRE servicio de deuda).
|
|
|
|
Incluye: vacancia + mgmt + mantenimiento + capex + tax + insurance + HOA.
|
|
"""
|
|
gross_rent_annual = rent_monthly * 12
|
|
vacancy = gross_rent_annual * vacancy_pct
|
|
mgmt = gross_rent_annual * mgmt_pct
|
|
maint = gross_rent_annual * maint_pct
|
|
capex = gross_rent_annual * capex_pct
|
|
total_opex = vacancy + mgmt + maint + capex + tax_annual + insurance_annual + (hoa_monthly * 12)
|
|
return {
|
|
"gross_rent_annual": round(gross_rent_annual, 2),
|
|
"vacancy_annual": round(vacancy, 2),
|
|
"mgmt_annual": round(mgmt, 2),
|
|
"maint_annual": round(maint, 2),
|
|
"capex_annual": round(capex, 2),
|
|
"tax_annual": round(tax_annual, 2),
|
|
"insurance_annual": round(insurance_annual, 2),
|
|
"hoa_annual": round(hoa_monthly * 12, 2),
|
|
"total_opex_annual": round(total_opex, 2),
|
|
}
|
|
|
|
|
|
def calculate_noi(rent_monthly: float, opex_annual: float) -> float:
|
|
"""Net Operating Income anual (PRE servicio de deuda).
|
|
|
|
NOI = Gross Rent Anual - Operating Expenses (incluye vacancia/mgmt/etc).
|
|
NO incluye servicio de deuda.
|
|
"""
|
|
gross = rent_monthly * 12
|
|
return round(gross - opex_annual, 2)
|
|
|
|
|
|
def calculate_dscr(noi_annual: float, piti_annual: float) -> float:
|
|
"""DSCR = NOI / Annual Debt Service.
|
|
|
|
PITI annual incluye P+I+T+I+HOA. Como NOI ya descuenta T+I+HOA en el opex,
|
|
el "debt service" puro deberia ser solo P+I anual. Para alinearse con como
|
|
los DSCR lenders calculan (que usan PITI completo), usamos PITI annual aqui.
|
|
|
|
Threshold pass: 1.20 typical, 1.25 estricto.
|
|
"""
|
|
if piti_annual <= 0:
|
|
return 0.0
|
|
return round(noi_annual / piti_annual, 3)
|
|
|
|
|
|
def calculate_cash_flow(rent_monthly: float, piti_monthly: float, opex_annual: float) -> dict:
|
|
"""Cash Flow mensual y anual (POST servicio de deuda).
|
|
|
|
Cash Flow = Gross Rent - PITI - Reservas operativas (sin tax/ins/HOA porque
|
|
ya estan en PITI).
|
|
"""
|
|
# Reservas operativas mensuales (sin tax/insurance/HOA - estan en PITI)
|
|
vacancy_m = rent_monthly * VACANCY_PCT
|
|
mgmt_m = rent_monthly * MGMT_PCT
|
|
maint_m = rent_monthly * MAINT_PCT
|
|
capex_m = rent_monthly * CAPEX_PCT
|
|
reserves_m = vacancy_m + mgmt_m + maint_m + capex_m
|
|
cf_monthly = rent_monthly - piti_monthly - reserves_m
|
|
return {
|
|
"cash_flow_monthly": round(cf_monthly, 2),
|
|
"cash_flow_annual": round(cf_monthly * 12, 2),
|
|
"reserves_monthly": round(reserves_m, 2),
|
|
}
|
|
|
|
|
|
def calculate_cap_rate(noi_annual: float, property_value: float) -> float:
|
|
"""Cap Rate = NOI / Property Value (unlevered yield).
|
|
|
|
Devuelve porcentaje (0-100), no decimal.
|
|
"""
|
|
if property_value <= 0:
|
|
return 0.0
|
|
return round(noi_annual / property_value * 100, 2)
|
|
|
|
|
|
def calculate_coc_return(annual_cash_flow: float, total_cash_invested: float) -> float:
|
|
"""Cash-on-Cash return = Annual Cash Flow / Cash Invested (leveraged ROI).
|
|
|
|
Devuelve porcentaje (0-100), no decimal.
|
|
"""
|
|
if total_cash_invested <= 0:
|
|
return 0.0
|
|
return round(annual_cash_flow / total_cash_invested * 100, 2)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Helpers para auto-estimar inputs faltantes
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def estimate_florida_insurance(price: float, flood_zone: Optional[str] = None) -> float:
|
|
"""Estima insurance anual segun el tier de precio FL y zona FEMA."""
|
|
base = next(insurance for limit, insurance in FL_INSURANCE_TIERS if price < limit)
|
|
if flood_zone:
|
|
zone = flood_zone.upper().strip()
|
|
if zone in ("VE", "V"):
|
|
base += FLOOD_INSURANCE_VE
|
|
elif zone in ("A", "AE", "AH", "AO", "AR"):
|
|
base += FLOOD_INSURANCE_AE
|
|
return base
|
|
|
|
|
|
def estimate_florida_property_tax(price: float) -> float:
|
|
"""FL property tax sin homestead: ~2% del precio."""
|
|
return price * 0.02
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ESCENARIO 1: Buy & Hold (DSCR loan)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_buy_and_hold(
|
|
price: float,
|
|
rent_monthly: float,
|
|
tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
down_payment_pct: float = 1 - DSCR_LOAN_LTV,
|
|
loan_rate: float = DSCR_LOAN_RATE,
|
|
loan_years: int = 30,
|
|
) -> dict:
|
|
"""Buy & Hold con DSCR loan.
|
|
|
|
Returns dict con todos los numeros + verdict.
|
|
"""
|
|
down_payment = price * down_payment_pct
|
|
loan_amount = price - down_payment
|
|
closing_costs_pct = 0.03
|
|
closing_costs = price * closing_costs_pct
|
|
total_cash_invested = down_payment + closing_costs
|
|
|
|
piti = calculate_piti(loan_amount, loan_rate, loan_years, tax_annual, insurance_annual, hoa_monthly)
|
|
opex = calculate_operating_expenses(rent_monthly, tax_annual, insurance_annual, hoa_monthly)
|
|
noi = calculate_noi(rent_monthly, opex["total_opex_annual"])
|
|
|
|
# DSCR usa P+I (no PITI) porque T+I ya estan en opex. Pero la convencion de
|
|
# los lenders es PITI/12*12 = piti_annual. Para alinearse con como lo computan:
|
|
# Lenders calculan: NOI/PITI annual (donde PITI excluye reservas operativas pero
|
|
# incluye T+I). Nuestro NOI ya descuenta T+I asi que sumarlos en el denominador
|
|
# los double-counts. Mejor: DSCR = (rent_annual - vacancy - opex_reserves) / piti_annual
|
|
# donde opex_reserves son las RESERVAS solamente (vacancia/mgmt/maint/capex), no T+I/HOA.
|
|
gross_rent_annual = rent_monthly * 12
|
|
reserves_only = gross_rent_annual * (VACANCY_PCT + MGMT_PCT + MAINT_PCT + CAPEX_PCT)
|
|
noi_for_dscr = gross_rent_annual - reserves_only # antes de T+I+HOA (esos van en PITI)
|
|
dscr = calculate_dscr(noi_for_dscr, piti["piti_annual"])
|
|
|
|
cf = calculate_cash_flow(rent_monthly, piti["piti_monthly"], opex["total_opex_annual"])
|
|
cap_rate = calculate_cap_rate(noi, price)
|
|
coc = calculate_coc_return(cf["cash_flow_annual"], total_cash_invested)
|
|
|
|
# Veredicto
|
|
pass_dscr = dscr >= BUYHOLD_DSCR_MIN
|
|
pass_cf = cf["cash_flow_monthly"] >= BUYHOLD_CASHFLOW_MIN
|
|
verdict = "PASA" if (pass_dscr and pass_cf) else "NO PASA"
|
|
reasons = []
|
|
if not pass_dscr:
|
|
reasons.append(f"DSCR {dscr:.2f} < {BUYHOLD_DSCR_MIN}")
|
|
if not pass_cf:
|
|
reasons.append(f"Cash Flow ${cf['cash_flow_monthly']:.0f}/mo < ${BUYHOLD_CASHFLOW_MIN}")
|
|
|
|
return {
|
|
"strategy": "Buy & Hold (DSCR loan)",
|
|
"down_payment": round(down_payment, 2),
|
|
"down_payment_pct": round(down_payment_pct * 100, 1),
|
|
"loan_amount": round(loan_amount, 2),
|
|
"loan_rate_pct": round(loan_rate * 100, 2),
|
|
"loan_years": loan_years,
|
|
"closing_costs": round(closing_costs, 2),
|
|
"total_cash_invested": round(total_cash_invested, 2),
|
|
"piti_monthly": piti["piti_monthly"],
|
|
"piti_annual": piti["piti_annual"],
|
|
"p_and_i_monthly": piti["p_and_i_monthly"],
|
|
"noi_annual": noi,
|
|
"dscr": dscr,
|
|
"cash_flow_monthly": cf["cash_flow_monthly"],
|
|
"cash_flow_annual": cf["cash_flow_annual"],
|
|
"cap_rate_pct": cap_rate,
|
|
"coc_return_pct": coc,
|
|
"verdict": verdict,
|
|
"verdict_reasons": reasons,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ESCENARIO 2: BRRRR
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_brrrr_metrics(
|
|
price: float,
|
|
rehab: float,
|
|
arv: float,
|
|
rent_monthly: float,
|
|
tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
refi_ltv: float = BRRRR_REFI_LTV,
|
|
refi_rate: float = BRRRR_REFI_RATE,
|
|
refi_years: int = 30,
|
|
hard_money_rate: float = HARD_MONEY_RATE,
|
|
hard_money_points: float = HARD_MONEY_POINTS,
|
|
hard_money_term_months: int = HARD_MONEY_TERM_MONTHS,
|
|
) -> dict:
|
|
"""BRRRR: Buy + Rehab + Rent + Refinance + Repeat.
|
|
|
|
Asume compra con hard money (90% LTC), rehab, refi al 75% LTV del ARV.
|
|
"""
|
|
# Acquisition phase
|
|
hard_money_ltc = 0.90 # 90% LTC tipico
|
|
hard_money_loan = price * hard_money_ltc
|
|
down_payment_acq = price - hard_money_loan
|
|
points_cost = hard_money_loan * hard_money_points
|
|
holding_interest = hard_money_loan * hard_money_rate * (hard_money_term_months / 12)
|
|
|
|
# Total cash invertido durante acquisition + rehab
|
|
cash_invested_initial = down_payment_acq + rehab + points_cost + holding_interest
|
|
|
|
# Refi phase
|
|
refi_loan = arv * refi_ltv
|
|
refi_payoff = hard_money_loan # paga hard money con refi
|
|
refi_closing_costs = refi_loan * 0.03
|
|
cash_recovered_at_refi = refi_loan - refi_payoff - refi_closing_costs
|
|
|
|
# Capital atrapado post-refi
|
|
trapped_capital = cash_invested_initial - cash_recovered_at_refi
|
|
trapped_pct = (trapped_capital / cash_invested_initial * 100) if cash_invested_initial > 0 else 0
|
|
|
|
# Cash flow post-refi
|
|
piti_post_refi = calculate_piti(refi_loan, refi_rate, refi_years, tax_annual, insurance_annual, hoa_monthly)
|
|
cf_post = calculate_cash_flow(rent_monthly, piti_post_refi["piti_monthly"], 0)
|
|
|
|
# Regla 70: precio + rehab <= 70% ARV
|
|
rule_70_max = arv * 0.70
|
|
rule_70_actual = price + rehab
|
|
rule_70_pass = rule_70_actual <= rule_70_max
|
|
|
|
# Veredicto
|
|
pass_trapped = trapped_pct <= BRRRR_TRAPPED_CAPITAL_PCT_MAX * 100
|
|
pass_cf = cf_post["cash_flow_monthly"] >= BUYHOLD_CASHFLOW_MIN
|
|
verdict = "PASA" if (pass_trapped and pass_cf and rule_70_pass) else "NO PASA"
|
|
reasons = []
|
|
if not rule_70_pass:
|
|
reasons.append(f"Regla 70 fallo: precio+rehab ${rule_70_actual:,.0f} > 70% ARV ${rule_70_max:,.0f}")
|
|
if not pass_trapped:
|
|
reasons.append(f"Capital atrapado {trapped_pct:.0f}% > {BRRRR_TRAPPED_CAPITAL_PCT_MAX*100:.0f}%")
|
|
if not pass_cf:
|
|
reasons.append(f"Cash Flow post-refi ${cf_post['cash_flow_monthly']:.0f} < ${BUYHOLD_CASHFLOW_MIN}")
|
|
|
|
return {
|
|
"strategy": "BRRRR",
|
|
"hard_money_loan": round(hard_money_loan, 2),
|
|
"hard_money_down_payment": round(down_payment_acq, 2),
|
|
"rehab_budget": round(rehab, 2),
|
|
"points_cost": round(points_cost, 2),
|
|
"holding_interest": round(holding_interest, 2),
|
|
"cash_invested_initial": round(cash_invested_initial, 2),
|
|
"refi_loan": round(refi_loan, 2),
|
|
"refi_closing_costs": round(refi_closing_costs, 2),
|
|
"cash_recovered_at_refi": round(cash_recovered_at_refi, 2),
|
|
"trapped_capital": round(trapped_capital, 2),
|
|
"trapped_capital_pct": round(trapped_pct, 1),
|
|
"piti_post_refi_monthly": piti_post_refi["piti_monthly"],
|
|
"cash_flow_post_refi_monthly": cf_post["cash_flow_monthly"],
|
|
"rule_70_max_price_plus_rehab": round(rule_70_max, 2),
|
|
"rule_70_actual_price_plus_rehab": round(rule_70_actual, 2),
|
|
"rule_70_pass": rule_70_pass,
|
|
"verdict": verdict,
|
|
"verdict_reasons": reasons,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ESCENARIO 3: Wholesale
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_mao_wholesale(arv: float, rehab: float) -> float:
|
|
"""MAO = ARV * 0.70 - rehab. Regla 70."""
|
|
return round(arv * WHOLESALE_ARV_PCT - rehab, 2)
|
|
|
|
|
|
def calculate_wholesale(price: float, arv: float, rehab: float) -> dict:
|
|
"""Wholesale: spread entre MAO y precio listado."""
|
|
mao = calculate_mao_wholesale(arv, rehab)
|
|
spread = mao - price
|
|
verdict = "PASA" if spread >= WHOLESALE_MIN_SPREAD else "NO PASA"
|
|
reasons = []
|
|
if spread < WHOLESALE_MIN_SPREAD:
|
|
reasons.append(f"Spread ${spread:,.0f} < ${WHOLESALE_MIN_SPREAD:,} minimo")
|
|
return {
|
|
"strategy": "Wholesale",
|
|
"mao": mao,
|
|
"list_price": round(price, 2),
|
|
"spread": round(spread, 2),
|
|
"verdict": verdict,
|
|
"verdict_reasons": reasons,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ESCENARIO 4: Section 8
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_section8_rent(fmr: float, multiplier: float = SECTION8_FMR_MULTIPLIER) -> float:
|
|
"""Section 8 paga 100-110% del FMR tipicamente. Asumimos multiplier=1.05."""
|
|
return round(fmr * multiplier, 2)
|
|
|
|
|
|
def calculate_section8_scenario(
|
|
price: float,
|
|
market_rent_monthly: float,
|
|
fmr_3br: float,
|
|
tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
) -> dict:
|
|
"""Section 8: usa renta FMR-based en lugar de market rent.
|
|
|
|
Compara renta Section 8 (FMR * multiplier) vs market rent.
|
|
"""
|
|
section8_rent = calculate_section8_rent(fmr_3br)
|
|
rent_advantage = section8_rent > market_rent_monthly
|
|
|
|
# Use higher of section8 vs market
|
|
effective_rent = max(section8_rent, market_rent_monthly)
|
|
|
|
bh = calculate_buy_and_hold(
|
|
price=price,
|
|
rent_monthly=effective_rent,
|
|
tax_annual=tax_annual,
|
|
insurance_annual=insurance_annual,
|
|
hoa_monthly=hoa_monthly,
|
|
)
|
|
|
|
# Section 8 requiere DSCR >= 1.25 (mas estricto)
|
|
pass_dscr = bh["dscr"] >= SECTION8_DSCR_MIN
|
|
pass_advantage = rent_advantage
|
|
verdict = "PASA" if (pass_dscr and pass_advantage) else "NO PASA"
|
|
reasons = []
|
|
if not pass_advantage:
|
|
reasons.append(f"Section 8 rent ${section8_rent:.0f} <= market rent ${market_rent_monthly:.0f}")
|
|
if not pass_dscr:
|
|
reasons.append(f"DSCR {bh['dscr']:.2f} < {SECTION8_DSCR_MIN}")
|
|
|
|
return {
|
|
"strategy": "Section 8",
|
|
"fmr_3br_monthly": round(fmr_3br, 2),
|
|
"section8_rent_monthly": section8_rent,
|
|
"market_rent_monthly": round(market_rent_monthly, 2),
|
|
"section8_advantage_usd": round(section8_rent - market_rent_monthly, 2),
|
|
"effective_rent_used": round(effective_rent, 2),
|
|
"dscr": bh["dscr"],
|
|
"cash_flow_monthly": bh["cash_flow_monthly"],
|
|
"cap_rate_pct": bh["cap_rate_pct"],
|
|
"coc_return_pct": bh["coc_return_pct"],
|
|
"verdict": verdict,
|
|
"verdict_reasons": reasons,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ESCENARIO 5: AUCTION ACQUISITION (Fix #2)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_mab_auction(
|
|
arv: float,
|
|
rehab: float,
|
|
title_reserve: Optional[float] = None,
|
|
county_name: Optional[str] = None,
|
|
surviving_debt: float = 0.0,
|
|
) -> dict:
|
|
"""Maximum Allowable Bid para auction acquisition.
|
|
|
|
Formula original:
|
|
MAB * 1.10 = ARV * 0.65 - rehab * 1.5 - title_reserve
|
|
MAB = (ARV * 0.65 - rehab * 1.5 - title_reserve) / 1.10
|
|
|
|
Wave 1.5A v1.2: surviving_debt parameter
|
|
Si court_records detecta liens que sobreviven el foreclosure (IRS,
|
|
municipal, property tax, HOA, senior mortgages), el buyer HEREDA esos
|
|
liens y debe sumarlos a su costo real. El effective_MAB = MAB_original
|
|
- surviving_debt.
|
|
|
|
title_reserve: $15K Miami-Dade, $5K otros condados.
|
|
"""
|
|
if title_reserve is None:
|
|
if county_name and "miami-dade" in county_name.lower():
|
|
title_reserve = TITLE_RESERVE_MIAMI_DADE
|
|
else:
|
|
title_reserve = TITLE_RESERVE_OTHER
|
|
|
|
numerator = arv * AUCTION_ARV_FLOOR - rehab * AUCTION_REHAB_WORST_CASE - title_reserve
|
|
mab_original = numerator / (1 + AUCTION_BUFFER_PCT)
|
|
mab_original = max(0, mab_original) # nunca negativo
|
|
|
|
# Effective MAB: el buyer puede pagar HASTA mab_original menos liens heredables
|
|
# porque esos liens deberan ser pagados post-cierre.
|
|
effective_mab = mab_original - max(0, surviving_debt)
|
|
effective_mab = max(0, effective_mab)
|
|
|
|
return {
|
|
"mab": round(effective_mab, 2), # mab "principal" para downstream consumers
|
|
"mab_original": round(mab_original, 2),
|
|
"mab_effective": round(effective_mab, 2),
|
|
"surviving_debt_deduction": round(surviving_debt, 2),
|
|
"arv": round(arv, 2),
|
|
"arv_floor_value": round(arv * AUCTION_ARV_FLOOR, 2),
|
|
"rehab_worst_case": round(rehab * AUCTION_REHAB_WORST_CASE, 2),
|
|
"title_reserve": round(title_reserve, 2),
|
|
"buffer_pct": AUCTION_BUFFER_PCT * 100,
|
|
}
|
|
|
|
|
|
def calculate_auction_scenario(
|
|
starting_bid: float,
|
|
arv: float,
|
|
rehab: float,
|
|
county_name: Optional[str] = None,
|
|
surviving_debt: float = 0.0,
|
|
) -> dict:
|
|
"""Auction acquisition con veredicto basado en MAB.
|
|
|
|
Verdicts:
|
|
PASS si starting_bid < effective_MAB * 0.70 (~30% safety margin)
|
|
CAUTION si effective_MAB*0.70 <= starting_bid < effective_MAB*0.95
|
|
NO BID si starting_bid >= effective_MAB * 0.95
|
|
|
|
Wave 1.5A v1.2: surviving_debt ajusta el MAB. Si el buyer hereda $30K de
|
|
liens, el effective_MAB baja $30K y el veredicto se vuelve mas conservador.
|
|
"""
|
|
mab_data = calculate_mab_auction(arv, rehab, county_name=county_name,
|
|
surviving_debt=surviving_debt)
|
|
mab = mab_data["mab"] # esto ya es el effective_mab
|
|
|
|
pass_threshold = mab * AUCTION_PASS_THRESHOLD
|
|
caution_threshold = mab * AUCTION_CAUTION_THRESHOLD
|
|
|
|
if starting_bid < pass_threshold:
|
|
verdict = "PASS"
|
|
verdict_reason = f"Starting bid ${starting_bid:,.0f} < MAB*0.70 (${pass_threshold:,.0f}) — gran margen de safety"
|
|
elif starting_bid < caution_threshold:
|
|
verdict = "CAUTION"
|
|
verdict_reason = f"Starting bid ${starting_bid:,.0f} entre MAB*0.70 (${pass_threshold:,.0f}) y MAB*0.95 (${caution_threshold:,.0f}) — margen apretado, requiere validacion exhaustiva"
|
|
else:
|
|
verdict = "NO BID"
|
|
verdict_reason = f"Starting bid ${starting_bid:,.0f} >= MAB*0.95 (${caution_threshold:,.0f}) — sin margen de safety, riesgo asimetrico negativo"
|
|
|
|
# Cash needed Day-1 (auctions son cash-only) + surviving debt que se paga post-cierre
|
|
cash_needed_day_one = starting_bid + mab_data["title_reserve"] # bid + reserve
|
|
cash_total_to_close = cash_needed_day_one + max(0, surviving_debt)
|
|
|
|
return {
|
|
"strategy": "Auction Acquisition",
|
|
"starting_bid": round(starting_bid, 2),
|
|
"mab": mab,
|
|
"mab_original": mab_data["mab_original"],
|
|
"mab_effective": mab_data["mab_effective"],
|
|
"surviving_debt_deduction": mab_data["surviving_debt_deduction"],
|
|
"mab_breakdown": mab_data,
|
|
"pass_threshold": round(pass_threshold, 2),
|
|
"caution_threshold": round(caution_threshold, 2),
|
|
"cash_needed_day_one": round(cash_needed_day_one, 2),
|
|
"cash_total_to_close": round(cash_total_to_close, 2),
|
|
"verdict": verdict,
|
|
"verdict_reason": verdict_reason,
|
|
"checklist_pre_bid": [
|
|
"Title search profesional ANTES del bid ($300-$500)",
|
|
"Code enforcement check con municipalidad",
|
|
"Occupancy drive-by (manejar por la calle)",
|
|
"Bankruptcy court check (auto-stay puede invalidar foreclosure)",
|
|
"PACER search del owner anterior",
|
|
],
|
|
"auction_risks": [
|
|
"Occupied status: eviction $1.5K-$5K + 30-90 dias (Florida legal process)",
|
|
"Foreclosure: posibilidad de equity stripping, junior liens sobreviven",
|
|
"Tax deed: 1-year redemption period (ex-owner puede recomprar)",
|
|
"Sin inspeccion previa: rehab puede ser 1.5x-2x del estimado",
|
|
"IRS liens NO se extinguen con foreclosure (heredas la deuda)",
|
|
"HOA pre-existing dues: posible herencia",
|
|
],
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Entry point: compute_all_scenarios
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
DealType = Literal["mls", "off_market", "auction", "foreclosure", "tax_deed", "reo"]
|
|
|
|
|
|
def compute_all_scenarios(
|
|
*,
|
|
price: float,
|
|
rent_monthly: float,
|
|
property_tax_annual: float,
|
|
insurance_annual: float,
|
|
hoa_monthly: float = 0,
|
|
arv: float = 0,
|
|
rehab: float = 0,
|
|
fmr_3br: Optional[float] = None,
|
|
flood_zone: Optional[str] = None,
|
|
county_name: Optional[str] = None,
|
|
deal_type: str = "mls",
|
|
surviving_debt: float = 0.0, # Wave 1.5A v1.2: liens heredables del court_records
|
|
) -> dict:
|
|
"""Computa los 4 escenarios estandar + escenario 5 si deal_type es auction.
|
|
|
|
Entry point principal. Devuelve dict con TODOS los numeros calculados,
|
|
listos para inyectar al prompt del DealAnalyzer.
|
|
|
|
Si fmr_3br es None, Section 8 se omite (no podemos calcular sin FMR).
|
|
"""
|
|
# Auto-estimar insurance si llega en 0 o muy bajo
|
|
if insurance_annual < 500:
|
|
insurance_annual = estimate_florida_insurance(price, flood_zone)
|
|
|
|
# Auto-estimar tax si llega en 0
|
|
if property_tax_annual < 500:
|
|
property_tax_annual = estimate_florida_property_tax(price)
|
|
|
|
deal_type_normalized = (deal_type or "mls").lower().strip()
|
|
is_auction = deal_type_normalized in ("auction", "foreclosure", "tax_deed", "reo")
|
|
|
|
scenarios: dict = {}
|
|
|
|
# 1. Buy & Hold
|
|
scenarios["buy_and_hold"] = calculate_buy_and_hold(
|
|
price=price,
|
|
rent_monthly=rent_monthly,
|
|
tax_annual=property_tax_annual,
|
|
insurance_annual=insurance_annual,
|
|
hoa_monthly=hoa_monthly,
|
|
)
|
|
|
|
# 2. BRRRR (si hay rehab y ARV)
|
|
if rehab > 0 and arv > price:
|
|
scenarios["brrrr"] = calculate_brrrr_metrics(
|
|
price=price,
|
|
rehab=rehab,
|
|
arv=arv,
|
|
rent_monthly=rent_monthly,
|
|
tax_annual=property_tax_annual,
|
|
insurance_annual=insurance_annual,
|
|
hoa_monthly=hoa_monthly,
|
|
)
|
|
else:
|
|
scenarios["brrrr"] = {
|
|
"strategy": "BRRRR",
|
|
"verdict": "N/A",
|
|
"verdict_reasons": ["Falta rehab>0 o ARV>price para evaluar BRRRR"],
|
|
}
|
|
|
|
# 3. Wholesale (si hay ARV)
|
|
if arv > 0:
|
|
scenarios["wholesale"] = calculate_wholesale(price=price, arv=arv, rehab=rehab)
|
|
else:
|
|
scenarios["wholesale"] = {
|
|
"strategy": "Wholesale",
|
|
"verdict": "N/A",
|
|
"verdict_reasons": ["Falta ARV para calcular MAO"],
|
|
}
|
|
|
|
# 4. Section 8 (si hay FMR)
|
|
if fmr_3br and fmr_3br > 0:
|
|
scenarios["section8"] = calculate_section8_scenario(
|
|
price=price,
|
|
market_rent_monthly=rent_monthly,
|
|
fmr_3br=fmr_3br,
|
|
tax_annual=property_tax_annual,
|
|
insurance_annual=insurance_annual,
|
|
hoa_monthly=hoa_monthly,
|
|
)
|
|
else:
|
|
scenarios["section8"] = {
|
|
"strategy": "Section 8",
|
|
"verdict": "N/A",
|
|
"verdict_reasons": ["Falta FMR del condado (HUD_API_KEY o fetcher fallo)"],
|
|
}
|
|
|
|
# 5. AUCTION (solo si deal_type indica auction)
|
|
if is_auction:
|
|
scenarios["auction"] = calculate_auction_scenario(
|
|
starting_bid=price, # en auctions, "price" del input es el starting bid
|
|
arv=arv,
|
|
rehab=rehab,
|
|
county_name=county_name,
|
|
surviving_debt=surviving_debt, # Wave 1.5A v1.2: ajusta effective_MAB
|
|
)
|
|
else:
|
|
scenarios["auction"] = {
|
|
"strategy": "Auction Acquisition",
|
|
"verdict": "N/A",
|
|
"verdict_reasons": [f"deal_type='{deal_type_normalized}' no es auction"],
|
|
}
|
|
|
|
# Best strategy: pick the one with PASS / PASS / PASS y mejor cash flow
|
|
candidates = []
|
|
for key in ("buy_and_hold", "brrrr", "section8", "auction"):
|
|
s = scenarios[key]
|
|
if s.get("verdict") == "PASA" or s.get("verdict") == "PASS":
|
|
cf = s.get("cash_flow_monthly", 0) or s.get("cash_flow_post_refi_monthly", 0) or 0
|
|
candidates.append((key, s["strategy"], cf))
|
|
candidates.sort(key=lambda x: x[2], reverse=True)
|
|
best = candidates[0][1] if candidates else "Ninguna estrategia pasa los thresholds"
|
|
|
|
# Bug 3: Anomaly Detection. Detecta metricas "demasiado buenas para ser
|
|
# verdad" que en USA real estate suelen indicar precio anomalo (data error,
|
|
# listing fake, hidden problem heredable, etc.).
|
|
anomalies = detect_anomalies(scenarios)
|
|
|
|
return {
|
|
"inputs_used": {
|
|
"price": price,
|
|
"rent_monthly": rent_monthly,
|
|
"property_tax_annual": property_tax_annual,
|
|
"insurance_annual": insurance_annual,
|
|
"hoa_monthly": hoa_monthly,
|
|
"arv": arv,
|
|
"rehab": rehab,
|
|
"fmr_3br": fmr_3br,
|
|
"flood_zone": flood_zone,
|
|
"county_name": county_name,
|
|
"deal_type": deal_type_normalized,
|
|
},
|
|
"scenarios": scenarios,
|
|
"best_strategy": best,
|
|
"is_auction": is_auction,
|
|
"anomalies": anomalies, # Bug 3
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Bug 3: Anomaly Detection
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
# Thresholds basados en benchmarks USA reales (Florida SFR):
|
|
# - Cap Rate tipico SFR: 4-7% (>10% ya es raro, >12% es bandera roja)
|
|
# - CoC tipico: 8-15% (>20% ya es raro, >25% es bandera roja)
|
|
# - DSCR tipico: 1.20-1.45 (>1.6 raro en FL post-insurance crisis, >1.7 bandera)
|
|
# Estos UMBRALES SUPERIORES no son "buenos" — son SOSPECHOSOS.
|
|
# Si las metricas pasan, hay 3 escenarios:
|
|
# (a) data error en los inputs (rent inflado, price bajo equivocado)
|
|
# (b) listing precio bajo por hidden problem heredable
|
|
# (c) deal REAL excepcional (raro, requiere validacion exhaustiva)
|
|
ANOMALY_THRESHOLDS = {
|
|
"cap_rate_pct_max": 12.0, # > 12% sospechoso
|
|
"coc_return_pct_max": 25.0, # > 25% sospechoso
|
|
"dscr_max": 1.70, # > 1.70 sospechoso
|
|
"cash_flow_monthly_max": 2500, # > $2,500/mo sospechoso en SFR FL <$300K
|
|
"brrrr_trapped_capital_pct_max": 5.0, # <5% trapped es raro (BRRRR perfecto = sospechoso)
|
|
"wholesale_spread_max": 80_000, # >$80K spread es muy raro en SFR
|
|
}
|
|
|
|
# Benchmarks USA tipicos para citar en explicaciones
|
|
TYPICAL_USA_BENCHMARKS = {
|
|
"cap_rate_pct_typical": "4-7% (SFR Florida tipico)",
|
|
"coc_return_pct_typical": "8-15% (Buy & Hold con leverage tipico)",
|
|
"dscr_typical": "1.20-1.45 (umbral lender DSCR loan)",
|
|
"cash_flow_monthly_typical": "$200-$600/mo (SFR Buy & Hold tipico)",
|
|
"brrrr_trapped_capital_typical": "10-25% del capital invertido (BRRRR normal)",
|
|
"wholesale_spread_typical": "$15K-$40K spread normal",
|
|
}
|
|
|
|
|
|
def detect_anomalies(scenarios: dict) -> dict:
|
|
"""Detecta metricas anomalas en los escenarios computados.
|
|
|
|
Returns dict con:
|
|
has_anomalies: bool
|
|
anomaly_count: int
|
|
flagged_metrics: list of dicts con {scenario, metric, value, threshold, severity, message}
|
|
recommendation: str
|
|
"""
|
|
flagged: list[dict] = []
|
|
|
|
bh = scenarios.get("buy_and_hold") or {}
|
|
if isinstance(bh, dict) and bh.get("verdict") in ("PASA", "PASS"):
|
|
# Cap rate anomalo
|
|
cr = bh.get("cap_rate_pct")
|
|
if cr is not None and cr > ANOMALY_THRESHOLDS["cap_rate_pct_max"]:
|
|
flagged.append({
|
|
"scenario": "Buy & Hold",
|
|
"metric": "Cap Rate",
|
|
"value": round(cr, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["cap_rate_pct_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["cap_rate_pct_typical"],
|
|
"severity": "HIGH" if cr > 15 else "MEDIUM",
|
|
"message": (
|
|
f"Cap Rate {cr:.2f}% supera el umbral de {ANOMALY_THRESHOLDS['cap_rate_pct_max']}%. "
|
|
f"Tipico USA: {TYPICAL_USA_BENCHMARKS['cap_rate_pct_typical']}. "
|
|
"Suele indicar rent inflada o price subvaluado por hidden problem."
|
|
),
|
|
})
|
|
# CoC anomalo
|
|
coc = bh.get("coc_return_pct")
|
|
if coc is not None and coc > ANOMALY_THRESHOLDS["coc_return_pct_max"]:
|
|
flagged.append({
|
|
"scenario": "Buy & Hold",
|
|
"metric": "Cash-on-Cash Return",
|
|
"value": round(coc, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["coc_return_pct_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["coc_return_pct_typical"],
|
|
"severity": "HIGH" if coc > 35 else "MEDIUM",
|
|
"message": (
|
|
f"CoC {coc:.2f}% supera el umbral de {ANOMALY_THRESHOLDS['coc_return_pct_max']}%. "
|
|
f"Tipico USA: {TYPICAL_USA_BENCHMARKS['coc_return_pct_typical']}. "
|
|
"Cuando es tan alto en SFR, validar inputs (rent? price?)."
|
|
),
|
|
})
|
|
# DSCR anomalo
|
|
dscr = bh.get("dscr")
|
|
if dscr is not None and dscr > ANOMALY_THRESHOLDS["dscr_max"]:
|
|
flagged.append({
|
|
"scenario": "Buy & Hold",
|
|
"metric": "DSCR",
|
|
"value": round(dscr, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["dscr_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["dscr_typical"],
|
|
"severity": "HIGH" if dscr > 2.0 else "MEDIUM",
|
|
"message": (
|
|
f"DSCR {dscr:.2f} supera el umbral de {ANOMALY_THRESHOLDS['dscr_max']}. "
|
|
f"Tipico USA: {TYPICAL_USA_BENCHMARKS['dscr_typical']}. "
|
|
"DSCR >1.7 en SFR FL post-insurance crisis es raro — revisar rent/PITI."
|
|
),
|
|
})
|
|
# Cash flow anomalo
|
|
cfm = bh.get("cash_flow_monthly")
|
|
if cfm is not None and cfm > ANOMALY_THRESHOLDS["cash_flow_monthly_max"]:
|
|
flagged.append({
|
|
"scenario": "Buy & Hold",
|
|
"metric": "Cash Flow mensual",
|
|
"value": round(cfm, 0),
|
|
"threshold": ANOMALY_THRESHOLDS["cash_flow_monthly_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["cash_flow_monthly_typical"],
|
|
"severity": "HIGH" if cfm > 4000 else "MEDIUM",
|
|
"message": (
|
|
f"Cash Flow ${cfm:,.0f}/mo supera ${ANOMALY_THRESHOLDS['cash_flow_monthly_max']:,}/mo. "
|
|
f"Tipico USA: {TYPICAL_USA_BENCHMARKS['cash_flow_monthly_typical']}."
|
|
),
|
|
})
|
|
|
|
# Section 8 con metricas anomalas
|
|
s8 = scenarios.get("section8") or {}
|
|
if isinstance(s8, dict) and s8.get("verdict") in ("PASA", "PASS"):
|
|
cr = s8.get("cap_rate_pct")
|
|
if cr is not None and cr > ANOMALY_THRESHOLDS["cap_rate_pct_max"]:
|
|
flagged.append({
|
|
"scenario": "Section 8",
|
|
"metric": "Cap Rate",
|
|
"value": round(cr, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["cap_rate_pct_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["cap_rate_pct_typical"],
|
|
"severity": "MEDIUM",
|
|
"message": f"Section 8 Cap Rate {cr:.2f}% supera umbral {ANOMALY_THRESHOLDS['cap_rate_pct_max']}%.",
|
|
})
|
|
coc = s8.get("coc_return_pct")
|
|
if coc is not None and coc > ANOMALY_THRESHOLDS["coc_return_pct_max"]:
|
|
flagged.append({
|
|
"scenario": "Section 8",
|
|
"metric": "Cash-on-Cash",
|
|
"value": round(coc, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["coc_return_pct_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["coc_return_pct_typical"],
|
|
"severity": "MEDIUM",
|
|
"message": f"Section 8 CoC {coc:.2f}% supera umbral {ANOMALY_THRESHOLDS['coc_return_pct_max']}%.",
|
|
})
|
|
|
|
# BRRRR con trapped capital sospechosamente bajo (BRRRR "perfecto" suele indicar
|
|
# ARV inflado o rehab subestimado)
|
|
br = scenarios.get("brrrr") or {}
|
|
if isinstance(br, dict) and "trapped_capital_pct" in br:
|
|
tc = br.get("trapped_capital_pct")
|
|
if tc is not None and tc < ANOMALY_THRESHOLDS["brrrr_trapped_capital_pct_max"]:
|
|
flagged.append({
|
|
"scenario": "BRRRR",
|
|
"metric": "Trapped Capital",
|
|
"value": round(tc, 2),
|
|
"threshold": ANOMALY_THRESHOLDS["brrrr_trapped_capital_pct_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["brrrr_trapped_capital_typical"],
|
|
"severity": "MEDIUM",
|
|
"message": (
|
|
f"BRRRR trapped capital {tc:.1f}% es sospechosamente bajo "
|
|
f"(typico: {TYPICAL_USA_BENCHMARKS['brrrr_trapped_capital_typical']}). "
|
|
"Suele indicar ARV inflado, rehab subestimado, o price anomalo."
|
|
),
|
|
})
|
|
|
|
# Wholesale spread excepcional (puede indicar ARV inflado o listing anomalo)
|
|
wh = scenarios.get("wholesale") or {}
|
|
if isinstance(wh, dict) and "spread" in wh:
|
|
sp = wh.get("spread")
|
|
if sp is not None and sp > ANOMALY_THRESHOLDS["wholesale_spread_max"]:
|
|
flagged.append({
|
|
"scenario": "Wholesale",
|
|
"metric": "Spread (MAO - listing)",
|
|
"value": round(sp, 0),
|
|
"threshold": ANOMALY_THRESHOLDS["wholesale_spread_max"],
|
|
"typical": TYPICAL_USA_BENCHMARKS["wholesale_spread_typical"],
|
|
"severity": "MEDIUM",
|
|
"message": (
|
|
f"Wholesale spread ${sp:,.0f} supera ${ANOMALY_THRESHOLDS['wholesale_spread_max']:,} "
|
|
f"(tipico: {TYPICAL_USA_BENCHMARKS['wholesale_spread_typical']}). "
|
|
"Validar ARV con comps reales antes de oferta."
|
|
),
|
|
})
|
|
|
|
# Recommendation segun cantidad y severidad
|
|
high_count = sum(1 for f in flagged if f["severity"] == "HIGH")
|
|
medium_count = sum(1 for f in flagged if f["severity"] == "MEDIUM")
|
|
total = len(flagged)
|
|
|
|
# 3+ MEDIUM stacked es por si solo HIGH-severity pattern (correlacionados sugieren input
|
|
# error sistematico o hidden problem que infla todos los ratios)
|
|
is_critical = (
|
|
high_count >= 2
|
|
or (high_count >= 1 and medium_count >= 2)
|
|
or medium_count >= 3
|
|
)
|
|
|
|
if total == 0:
|
|
recommendation = "Sin anomalias detectadas — metricas dentro de benchmarks USA tipicos."
|
|
elif is_critical:
|
|
recommendation = (
|
|
f"🚨 {total} metrica(s) anomala(s) detectada(s) ({high_count} HIGH, {medium_count} MEDIUM). "
|
|
"El deal es 'demasiado bueno para ser verdad' — tratar como sospechoso. "
|
|
"Validar inputs (rent? price? ARV?) Y ejecutar due diligence completa "
|
|
"(court records, code enforcement, title search) ANTES de cualquier oferta. "
|
|
"Los numeros 'espectaculares' pueden ser ilusion derivada de input incorrecto "
|
|
"o de un precio anomalo por hidden problem heredable."
|
|
)
|
|
elif total >= 1:
|
|
recommendation = (
|
|
f"⚠️ {total} metrica(s) sobre benchmark USA tipico. "
|
|
"Re-verificar inputs antes de proceder. Posibles causas: rent over-estimada, "
|
|
"rehab sub-estimada, ARV inflado, o deal realmente excepcional (raro). "
|
|
"Cross-check con comps de mercado recientes."
|
|
)
|
|
else:
|
|
recommendation = "Metricas razonables."
|
|
|
|
return {
|
|
"has_anomalies": total > 0,
|
|
"is_critical": is_critical if total > 0 else False,
|
|
"anomaly_count": total,
|
|
"high_severity_count": high_count,
|
|
"medium_severity_count": medium_count,
|
|
"flagged_metrics": flagged,
|
|
"recommendation": recommendation,
|
|
"thresholds_used": ANOMALY_THRESHOLDS,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Formato para prompt (markdown block ready para inyectar al DealAnalyzer)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _money(x: float) -> str:
|
|
if x is None:
|
|
return "—"
|
|
return f"${x:,.0f}"
|
|
|
|
|
|
def _pct(x: float) -> str:
|
|
if x is None:
|
|
return "—"
|
|
return f"{x:.2f}%"
|
|
|
|
|
|
def build_calculated_block(computed: dict) -> str:
|
|
"""Genera un bloque markdown con TODOS los numeros pre-calculados.
|
|
|
|
Este bloque se inyecta al prompt del DealAnalyzer ANTES de pedir su analisis.
|
|
El DealAnalyzer solo INTERPRETA estos numeros, NO los recalcula.
|
|
"""
|
|
s = computed["scenarios"]
|
|
inputs = computed["inputs_used"]
|
|
|
|
bh = s["buy_and_hold"]
|
|
br = s["brrrr"]
|
|
wh = s["wholesale"]
|
|
s8 = s["section8"]
|
|
au = s["auction"]
|
|
|
|
lines = []
|
|
lines.append("=== NUMEROS PRE-CALCULADOS (FUENTE: finance_calculator.py) ===")
|
|
lines.append("USA estos numeros EXACTOS. NO recalcules. NO contradigas los veredictos.")
|
|
lines.append("Tu tarea es INTERPRETAR y RECOMENDAR, no recalcular.")
|
|
lines.append("")
|
|
|
|
# Bug 3: Anomaly Detection block (al INICIO del bloque calculado para que el
|
|
# LLM lo lea antes que las metricas individuales).
|
|
anomalies = computed.get("anomalies") or {}
|
|
if anomalies.get("has_anomalies"):
|
|
lines.append("--- ⚠️ ANOMALIAS DETECTADAS (Bug 3 — auto-flag) ---")
|
|
lines.append(f"Total: {anomalies['anomaly_count']} metrica(s) sospechosa(s) "
|
|
f"({anomalies['high_severity_count']} HIGH, {anomalies['medium_severity_count']} MEDIUM).")
|
|
lines.append("")
|
|
lines.append("**Recomendacion del sistema:** " + anomalies["recommendation"])
|
|
lines.append("")
|
|
lines.append("**Metricas flageadas (umbrales USA reales):**")
|
|
lines.append("")
|
|
lines.append("| Escenario | Metrica | Valor | Umbral | Tipico USA | Severidad |")
|
|
lines.append("|---|---|---|---|---|---|")
|
|
for f in anomalies["flagged_metrics"]:
|
|
val = f["value"]
|
|
val_str = f"{val:.2f}" if isinstance(val, float) and val < 100 else f"{val:,.0f}"
|
|
lines.append(
|
|
f"| {f['scenario']} | {f['metric']} | **{val_str}** | "
|
|
f"> {f['threshold']} | {f['typical']} | **{f['severity']}** |"
|
|
)
|
|
lines.append("")
|
|
lines.append("**INSTRUCCION OBLIGATORIA AL DEALANALYZER:**")
|
|
lines.append("Si arriba hay anomalias flageadas, tu output DEBE incluir una seccion titulada exactamente '## ⚠️ Validacion de Inputs Requerida' explicando que las metricas son sospechosas, las 3 causas posibles (data error / hidden problem heredable / deal real excepcional), y 4-6 acciones concretas de validacion antes de cualquier oferta. Esto es NO-NEGOCIABLE incluso si el veredicto calculado dice PASA — un PASS con metricas anomalas requiere validacion antes de oferta.")
|
|
lines.append("")
|
|
else:
|
|
lines.append("--- Anomaly check ---")
|
|
lines.append("Sin anomalias detectadas — metricas dentro de benchmarks USA tipicos (cap rate 4-7%, CoC 8-15%, DSCR 1.20-1.45).")
|
|
lines.append("")
|
|
lines.append("--- INPUTS NORMALIZADOS ---")
|
|
lines.append(f"- Precio: {_money(inputs['price'])} | Renta: {_money(inputs['rent_monthly'])}/mo")
|
|
lines.append(f"- Property tax: {_money(inputs['property_tax_annual'])}/year | Insurance: {_money(inputs['insurance_annual'])}/year (auto-estimada si era 0)")
|
|
lines.append(f"- HOA: {_money(inputs['hoa_monthly'])}/mo | ARV: {_money(inputs['arv'])} | Rehab: {_money(inputs['rehab'])}")
|
|
lines.append(f"- Flood zone: {inputs.get('flood_zone', 'N/A')} | County: {inputs.get('county_name', 'N/A')}")
|
|
lines.append(f"- Deal type: {inputs['deal_type']}")
|
|
if inputs.get('fmr_3br'):
|
|
lines.append(f"- HUD FMR 3BR: {_money(inputs['fmr_3br'])}/mo")
|
|
lines.append("")
|
|
|
|
# Tabla comparativa
|
|
lines.append("--- TABLA COMPARATIVA DE ESCENARIOS (calculada) ---")
|
|
lines.append("")
|
|
lines.append("| Estrategia | DSCR | Cash Flow/mo | Cap Rate | CoC | Veredicto (DATO CERRADO) |")
|
|
lines.append("|---|---|---|---|---|---|")
|
|
lines.append(
|
|
f"| Buy & Hold | {bh.get('dscr', '—'):.2f} | {_money(bh.get('cash_flow_monthly'))} | "
|
|
f"{_pct(bh.get('cap_rate_pct'))} | {_pct(bh.get('coc_return_pct'))} | **{bh.get('verdict')}** |"
|
|
)
|
|
if "trapped_capital_pct" in br:
|
|
lines.append(
|
|
f"| BRRRR | — | {_money(br.get('cash_flow_post_refi_monthly'))} | — | — | "
|
|
f"**{br.get('verdict')}** (capital atrapado {br.get('trapped_capital_pct', '—')}%) |"
|
|
)
|
|
else:
|
|
lines.append(f"| BRRRR | — | — | — | — | **{br.get('verdict')}** |")
|
|
if "mao" in wh:
|
|
lines.append(f"| Wholesale | — | MAO {_money(wh.get('mao'))} (spread {_money(wh.get('spread'))}) | — | — | **{wh.get('verdict')}** |")
|
|
else:
|
|
lines.append(f"| Wholesale | — | — | — | — | **{wh.get('verdict')}** |")
|
|
if "dscr" in s8:
|
|
lines.append(
|
|
f"| Section 8 | {s8.get('dscr', '—'):.2f} | {_money(s8.get('cash_flow_monthly'))} | "
|
|
f"{_pct(s8.get('cap_rate_pct'))} | {_pct(s8.get('coc_return_pct'))} | **{s8.get('verdict')}** |"
|
|
)
|
|
else:
|
|
lines.append(f"| Section 8 | — | — | — | — | **{s8.get('verdict')}** |")
|
|
if computed["is_auction"]:
|
|
lines.append(f"| Auction | — | MAB {_money(au.get('mab'))} | — | — | **{au.get('verdict')}** |")
|
|
|
|
lines.append("")
|
|
lines.append(f"**Mejor estrategia (calculada por veredicto + cash flow):** {computed['best_strategy']}")
|
|
lines.append("")
|
|
|
|
# Detalles Buy & Hold
|
|
if "dscr" in bh:
|
|
lines.append("--- DETALLE: Buy & Hold ---")
|
|
lines.append(f"- Down payment: {_money(bh['down_payment'])} ({bh['down_payment_pct']}%)")
|
|
lines.append(f"- Loan amount: {_money(bh['loan_amount'])} @ {bh['loan_rate_pct']}% por {bh['loan_years']} anos")
|
|
lines.append(f"- PITI mensual: {_money(bh['piti_monthly'])} (P+I: {_money(bh['p_and_i_monthly'])})")
|
|
lines.append(f"- NOI anual: {_money(bh['noi_annual'])}")
|
|
lines.append(f"- DSCR: {bh['dscr']:.2f} (threshold pass: {BUYHOLD_DSCR_MIN})")
|
|
lines.append(f"- Cash flow mensual: {_money(bh['cash_flow_monthly'])} (threshold pass: ${BUYHOLD_CASHFLOW_MIN}+)")
|
|
lines.append(f"- Total cash invertido: {_money(bh['total_cash_invested'])}")
|
|
if bh.get("verdict_reasons"):
|
|
lines.append("- Razones del veredicto: " + "; ".join(bh["verdict_reasons"]))
|
|
lines.append("")
|
|
|
|
# Detalles BRRRR
|
|
if "trapped_capital_pct" in br:
|
|
lines.append("--- DETALLE: BRRRR ---")
|
|
lines.append(f"- Hard money loan: {_money(br['hard_money_loan'])} @ {HARD_MONEY_RATE*100}% por {HARD_MONEY_TERM_MONTHS} meses")
|
|
lines.append(f"- Cash inicial requerido: {_money(br['cash_invested_initial'])}")
|
|
lines.append(f"- Refi loan post-rehab: {_money(br['refi_loan'])} ({BRRRR_REFI_LTV*100:.0f}% LTV del ARV)")
|
|
lines.append(f"- Cash recuperado al refi: {_money(br['cash_recovered_at_refi'])}")
|
|
lines.append(f"- Capital atrapado final: {_money(br['trapped_capital'])} ({br['trapped_capital_pct']}% del invertido)")
|
|
lines.append(f"- Cash flow post-refi: {_money(br['cash_flow_post_refi_monthly'])}/mo")
|
|
lines.append(f"- Regla 70: precio+rehab {_money(br['rule_70_actual_price_plus_rehab'])} vs max {_money(br['rule_70_max_price_plus_rehab'])} = {'PASS' if br['rule_70_pass'] else 'FAIL'}")
|
|
if br.get("verdict_reasons"):
|
|
lines.append("- Razones del veredicto: " + "; ".join(br["verdict_reasons"]))
|
|
lines.append("")
|
|
|
|
# Detalles Wholesale
|
|
if "mao" in wh:
|
|
lines.append("--- DETALLE: Wholesale ---")
|
|
lines.append(f"- MAO: {_money(wh['mao'])} (ARV * 0.70 - rehab)")
|
|
lines.append(f"- List price: {_money(wh['list_price'])}")
|
|
lines.append(f"- Spread: {_money(wh['spread'])} (threshold pass: ${WHOLESALE_MIN_SPREAD:,}+)")
|
|
if wh.get("verdict_reasons"):
|
|
lines.append("- Razones del veredicto: " + "; ".join(wh["verdict_reasons"]))
|
|
lines.append("")
|
|
|
|
# Detalles Section 8
|
|
if "dscr" in s8:
|
|
lines.append("--- DETALLE: Section 8 ---")
|
|
lines.append(f"- HUD FMR 3BR: {_money(s8['fmr_3br_monthly'])}/mo")
|
|
lines.append(f"- Section 8 rent estimado: {_money(s8['section8_rent_monthly'])}/mo (FMR x {SECTION8_FMR_MULTIPLIER})")
|
|
lines.append(f"- Market rent: {_money(s8['market_rent_monthly'])}/mo")
|
|
lines.append(f"- Ventaja Section 8 vs market: {_money(s8['section8_advantage_usd'])}/mo")
|
|
lines.append(f"- DSCR con renta efectiva: {s8['dscr']:.2f} (threshold Section 8: {SECTION8_DSCR_MIN})")
|
|
if s8.get("verdict_reasons"):
|
|
lines.append("- Razones del veredicto: " + "; ".join(s8["verdict_reasons"]))
|
|
lines.append("")
|
|
|
|
# Detalles AUCTION (si aplica)
|
|
if computed["is_auction"] and "mab" in au:
|
|
lines.append("--- DETALLE: AUCTION ACQUISITION ---")
|
|
mb = au["mab_breakdown"]
|
|
lines.append(f"- ARV: {_money(mb['arv'])} | ARV*0.65 = {_money(mb['arv_floor_value'])}")
|
|
lines.append(f"- Rehab worst-case (x{AUCTION_REHAB_WORST_CASE}): {_money(mb['rehab_worst_case'])}")
|
|
lines.append(f"- Title reserve: {_money(mb['title_reserve'])}")
|
|
# Wave 1.5A v1.2: distinguir MAB original vs effective si hay surviving_debt
|
|
surviving = au.get("surviving_debt_deduction", 0) or 0
|
|
if surviving > 0:
|
|
lines.append(f"- **MAB original** (sin surviving debt): {_money(au['mab_original'])} (con buffer {mb['buffer_pct']:.0f}%)")
|
|
lines.append(f"- **Surviving debt heredable** (liens IRS/municipal/HOA/etc): -{_money(surviving)}")
|
|
lines.append(f"- **MAB EFFECTIVE** (lo que realmente podes pagar al auction): **{_money(au['mab_effective'])}**")
|
|
else:
|
|
lines.append(f"- MAB calculado: **{_money(au['mab'])}** (con buffer {mb['buffer_pct']:.0f}%)")
|
|
lines.append(f"- Starting bid: {_money(au['starting_bid'])}")
|
|
lines.append(f"- Threshold PASS: < {_money(au['pass_threshold'])} (effective_MAB * 0.70)")
|
|
lines.append(f"- Threshold CAUTION: < {_money(au['caution_threshold'])} (effective_MAB * 0.95)")
|
|
lines.append(f"- **VEREDICTO: {au['verdict']}** — {au['verdict_reason']}")
|
|
lines.append(f"- Cash needed Day-1 (bid + title reserve): {_money(au['cash_needed_day_one'])}")
|
|
if surviving > 0:
|
|
lines.append(f"- **Cash TOTAL to close** (Day-1 + liens heredables): {_money(au.get('cash_total_to_close', 0))}")
|
|
lines.append("- Checklist pre-bid:")
|
|
for item in au["checklist_pre_bid"]:
|
|
lines.append(f" * {item}")
|
|
lines.append("- Riesgos de auction:")
|
|
for risk in au["auction_risks"]:
|
|
lines.append(f" * {risk}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Owner-Occupied / Live-In Scenarios (bug fix 2026-05-15)
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_live_in_scenario(
|
|
purchase_price: float,
|
|
down_payment: float,
|
|
*,
|
|
loan_type: str = "fha", # "fha" | "conventional_oo" | "va"
|
|
years: int = 30,
|
|
tax_annual: float = 0,
|
|
insurance_annual: float = 0,
|
|
hoa_monthly: float = 0,
|
|
rent_room_monthly: float = 0,
|
|
monthly_income: float = 0,
|
|
other_monthly_debts: float = 0,
|
|
) -> dict:
|
|
"""Live-in scenario: compras para VIVIR + opcional house-hack (rent a room).
|
|
|
|
Args:
|
|
purchase_price: precio de compra (asking o oferta)
|
|
down_payment: cuanto vas a poner cash
|
|
loan_type: 'fha' (3.5% min down) | 'conventional_oo' (5% min) | 'va' (0%)
|
|
years: typically 30
|
|
tax_annual / insurance_annual / hoa_monthly: holding costs
|
|
rent_room_monthly: si vas a alquilar un cuarto, cuanto te pagan
|
|
monthly_income: tu ingreso bruto mensual (para DTI)
|
|
other_monthly_debts: pagos otros (car, student loan, credit cards)
|
|
|
|
Returns:
|
|
{
|
|
"loan_type": str,
|
|
"rate": float, # tasa anual
|
|
"loan_amount": float,
|
|
"down_payment": float,
|
|
"down_pct": float,
|
|
"p_and_i_monthly": float,
|
|
"mip_or_pmi_monthly": float, # MIP FHA o PMI conventional si <20%
|
|
"tax_monthly": float,
|
|
"insurance_monthly": float,
|
|
"hoa_monthly": float,
|
|
"piti_total_monthly": float, # incluye PMI/MIP
|
|
"rent_room_monthly": float,
|
|
"net_payment_monthly": float, # PITI - rent_room = lo que sales del bolsillo
|
|
"dti_evaluation": { # solo si monthly_income > 0
|
|
"monthly_income": float,
|
|
"front_end_dti_pct": float,
|
|
"back_end_dti_pct": float,
|
|
"verdict": "AFFORDABLE" | "TIGHT" | "RISKY" | "WONT_QUALIFY",
|
|
"verdict_es": str, # version humana en español
|
|
"rationale": str,
|
|
} | None,
|
|
"scenarios_with_room": [ # net payment para distintos rent levels
|
|
{"rent_room": int, "net_payment": float}, ...
|
|
],
|
|
}
|
|
"""
|
|
purchase_price = max(0, purchase_price)
|
|
down_payment = max(0, min(down_payment, purchase_price))
|
|
loan_amount = purchase_price - down_payment
|
|
down_pct = (down_payment / purchase_price) if purchase_price > 0 else 0
|
|
|
|
# Pick rate + MIP/PMI by loan type
|
|
if loan_type == "fha":
|
|
rate = FHA_RATE
|
|
mip_or_pmi_annual = loan_amount * FHA_MIP_RATE
|
|
elif loan_type == "conventional_oo":
|
|
rate = CONVENTIONAL_OO_RATE
|
|
# PMI if down < 20%
|
|
mip_or_pmi_annual = loan_amount * CONVENTIONAL_OO_PMI_RATE if down_pct < 0.20 else 0
|
|
elif loan_type == "va":
|
|
rate = VA_RATE
|
|
mip_or_pmi_annual = 0 # VA no PMI
|
|
else:
|
|
rate = CONVENTIONAL_OO_RATE
|
|
mip_or_pmi_annual = loan_amount * CONVENTIONAL_OO_PMI_RATE if down_pct < 0.20 else 0
|
|
|
|
p_and_i = calculate_monthly_mortgage_payment(loan_amount, rate, years)
|
|
mip_or_pmi_monthly = mip_or_pmi_annual / 12.0
|
|
tax_monthly = tax_annual / 12.0
|
|
insurance_monthly = insurance_annual / 12.0
|
|
piti_total = p_and_i + mip_or_pmi_monthly + tax_monthly + insurance_monthly + hoa_monthly
|
|
|
|
net_payment = piti_total - rent_room_monthly
|
|
|
|
# Compute multi-room scenarios — buyer puede pensar en rentar 0, 1, 2 cuartos
|
|
scenarios_with_room = []
|
|
for r in (0, 800, 1000, 1200, 1500, 2000):
|
|
scenarios_with_room.append({
|
|
"rent_room_monthly": r,
|
|
"net_payment_monthly": round(piti_total - r, 2),
|
|
})
|
|
|
|
# DTI evaluation
|
|
dti_eval = None
|
|
if monthly_income and monthly_income > 0:
|
|
front_end = (piti_total / monthly_income) * 100
|
|
back_end = ((piti_total + other_monthly_debts) / monthly_income) * 100
|
|
# Pick thresholds based on loan type
|
|
if loan_type == "fha":
|
|
fe_max = DTI_FRONT_END_FHA * 100
|
|
be_max = DTI_BACK_END_FHA * 100
|
|
else:
|
|
fe_max = DTI_FRONT_END_CONVENTIONAL * 100
|
|
be_max = DTI_BACK_END_CONVENTIONAL * 100
|
|
|
|
if front_end <= fe_max * 0.9 and back_end <= be_max * 0.9:
|
|
verdict = "AFFORDABLE"
|
|
verdict_es = "Si, podes pagarla comodamente"
|
|
rationale = f"Front-end DTI {front_end:.1f}% <= {fe_max*0.9:.0f}% (buffer), Back-end {back_end:.1f}% <= {be_max*0.9:.0f}%. Te queda margen para imprevistos."
|
|
elif front_end <= fe_max and back_end <= be_max:
|
|
verdict = "TIGHT"
|
|
verdict_es = "Podes pagarla pero ajustado"
|
|
rationale = f"Front-end {front_end:.1f}% dentro de limite ({fe_max:.0f}%) pero sin buffer. Imprevistos te complican. Considera rentar un cuarto para aliviar."
|
|
elif front_end <= DTI_FRONT_END_AGGRESSIVE_MAX * 100 or back_end <= be_max * 1.1:
|
|
verdict = "RISKY"
|
|
verdict_es = "Riesgoso — sale del confort normal"
|
|
rationale = f"Front-end {front_end:.1f}% supera limite ({fe_max:.0f}%). Te exiges. Algunos lenders aprueban con compensating factors (reservas, credit score 720+) pero NO recomendable."
|
|
else:
|
|
verdict = "WONT_QUALIFY"
|
|
verdict_es = "No vas a calificar / muy riesgoso"
|
|
rationale = f"Front-end {front_end:.1f}% o back-end {back_end:.1f}% sobre maximo absoluto. Bancos rechazan. Bajar precio, aumentar down payment, o ingresar mas income."
|
|
|
|
dti_eval = {
|
|
"monthly_income": round(monthly_income, 2),
|
|
"other_monthly_debts": round(other_monthly_debts, 2),
|
|
"front_end_dti_pct": round(front_end, 1),
|
|
"back_end_dti_pct": round(back_end, 1),
|
|
"front_end_max_pct": fe_max,
|
|
"back_end_max_pct": be_max,
|
|
"verdict": verdict,
|
|
"verdict_es": verdict_es,
|
|
"rationale": rationale,
|
|
}
|
|
|
|
return {
|
|
"loan_type": loan_type.upper(),
|
|
"rate": rate,
|
|
"rate_pct": round(rate * 100, 3),
|
|
"loan_amount": round(loan_amount, 2),
|
|
"down_payment": round(down_payment, 2),
|
|
"down_pct": round(down_pct * 100, 1),
|
|
"p_and_i_monthly": round(p_and_i, 2),
|
|
"mip_or_pmi_monthly": round(mip_or_pmi_monthly, 2),
|
|
"mip_or_pmi_label": "MIP" if loan_type == "fha" else ("PMI" if down_pct < 0.20 else ""),
|
|
"tax_monthly": round(tax_monthly, 2),
|
|
"insurance_monthly": round(insurance_monthly, 2),
|
|
"hoa_monthly": round(hoa_monthly, 2),
|
|
"piti_total_monthly": round(piti_total, 2),
|
|
"rent_room_monthly": round(rent_room_monthly, 2),
|
|
"net_payment_monthly": round(net_payment, 2),
|
|
"scenarios_with_room": scenarios_with_room,
|
|
"dti_evaluation": dti_eval,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Max profitable offer
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_max_profitable_offer(
|
|
*,
|
|
arv: float,
|
|
rehab_estimate: float = 0,
|
|
target_margin_pct: float = TARGET_INVESTOR_MARGIN_PCT,
|
|
closing_costs_pct: float = 0.02, # 2% closing
|
|
holding_months: int = 4,
|
|
holding_cost_monthly: float = 600, # tax + insurance + utilities while holding
|
|
selling_costs_pct: float = 0.08, # 8% (6% commission + 2% misc)
|
|
) -> dict:
|
|
"""Maximum profitable offer para fix-n-flip investor.
|
|
|
|
Math:
|
|
Net Proceeds = ARV * (1 - selling_costs_pct)
|
|
Target Profit = ARV * target_margin_pct
|
|
Holding Costs = holding_months * holding_cost_monthly
|
|
Max Offer = Net Proceeds - Target Profit - rehab - holding - closing
|
|
= ARV * (1 - selling_pct) - ARV * target_pct - rehab - holding - ARV * closing_pct
|
|
|
|
Returns:
|
|
{
|
|
"arv": float,
|
|
"rehab_estimate": float,
|
|
"target_profit": float,
|
|
"selling_costs": float,
|
|
"closing_costs": float,
|
|
"holding_costs": float,
|
|
"max_offer": float,
|
|
"max_offer_pct_of_arv": float,
|
|
"justification": str (human-readable),
|
|
}
|
|
"""
|
|
if arv <= 0:
|
|
return {"error": "ARV must be positive"}
|
|
|
|
net_proceeds = arv * (1 - selling_costs_pct)
|
|
target_profit = arv * target_margin_pct
|
|
selling_costs = arv * selling_costs_pct
|
|
closing_costs = arv * closing_costs_pct
|
|
holding_costs = holding_months * holding_cost_monthly
|
|
|
|
max_offer = net_proceeds - target_profit - rehab_estimate - holding_costs - closing_costs
|
|
max_offer = max(0, max_offer)
|
|
|
|
max_offer_pct = (max_offer / arv * 100) if arv > 0 else 0
|
|
|
|
justification = (
|
|
f"ARV ${arv:,.0f}. "
|
|
f"Tras gastos de venta ({selling_costs_pct*100:.0f}%) = ${net_proceeds:,.0f} neto. "
|
|
f"Quitando profit target ({target_margin_pct*100:.0f}% = ${target_profit:,.0f}), "
|
|
f"rehab estimado ${rehab_estimate:,.0f}, "
|
|
f"holding {holding_months}mo (${holding_costs:,.0f}), "
|
|
f"closing ({closing_costs_pct*100:.0f}% = ${closing_costs:,.0f}). "
|
|
f"Maxima oferta rentable: ${max_offer:,.0f} ({max_offer_pct:.0f}% de ARV)."
|
|
)
|
|
|
|
return {
|
|
"arv": round(arv, 2),
|
|
"rehab_estimate": round(rehab_estimate, 2),
|
|
"target_profit": round(target_profit, 2),
|
|
"target_margin_pct": round(target_margin_pct * 100, 1),
|
|
"selling_costs": round(selling_costs, 2),
|
|
"closing_costs": round(closing_costs, 2),
|
|
"holding_costs": round(holding_costs, 2),
|
|
"max_offer": round(max_offer, 2),
|
|
"max_offer_pct_of_arv": round(max_offer_pct, 1),
|
|
"justification": justification,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Multi-price-point payment table
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
def calculate_payment_table(
|
|
*,
|
|
asking_price: float,
|
|
max_offer: float,
|
|
down_payment: float,
|
|
annual_rate: float = CONVENTIONAL_OO_RATE,
|
|
years: int = 30,
|
|
tax_annual: float = 0,
|
|
insurance_annual: float = 0,
|
|
hoa_monthly: float = 0,
|
|
) -> list[dict]:
|
|
"""Multi-price payment table: para asking / midpoint / max_offer, calcula PITI mensual.
|
|
|
|
Returns: lista de dicts [{label, price, down, loan_amount, piti_monthly, p_and_i_only}, ...]
|
|
"""
|
|
if asking_price <= 0 or max_offer <= 0:
|
|
return []
|
|
midpoint = (asking_price + max_offer) / 2
|
|
|
|
rows = []
|
|
for label, price in [
|
|
("Maximum profitable offer", max_offer),
|
|
("Negotiation midpoint", midpoint),
|
|
("Asking price", asking_price),
|
|
]:
|
|
loan = max(0, price - down_payment)
|
|
p_and_i = calculate_monthly_mortgage_payment(loan, annual_rate, years)
|
|
tax_m = tax_annual / 12.0
|
|
ins_m = insurance_annual / 12.0
|
|
piti = p_and_i + tax_m + ins_m + hoa_monthly
|
|
rows.append({
|
|
"label": label,
|
|
"price": round(price, 2),
|
|
"down_payment": round(min(down_payment, price), 2),
|
|
"loan_amount": round(loan, 2),
|
|
"p_and_i_only_monthly": round(p_and_i, 2),
|
|
"tax_monthly": round(tax_m, 2),
|
|
"insurance_monthly": round(ins_m, 2),
|
|
"hoa_monthly": round(hoa_monthly, 2),
|
|
"piti_monthly": round(piti, 2),
|
|
})
|
|
return rows
|