Files
AR-House/finance_calculator.py
T
2026-07-03 12:24:58 -04:00

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