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