From 87df16d5336dda46789b40a0a69971ee319a698b Mon Sep 17 00:00:00 2001 From: Phil Ruff Date: Fri, 31 Jul 2026 09:49:49 +0100 Subject: [PATCH] refactor(finance): split finance utils into focused modules Split the 2,347-line investor_app/finance/utils.py into mortgage, taxes, scoring, and strategies submodules; utils.py now holds only core KPI primitives and the Django-coupled analysis. Update all 17 importers to canonical locations. Resolve LIMIT-20: delete the calculate_* aliases, the dead score_listing_v1 chain, the duplicate pure score_listing_v2, and the unused service-layer calculate_noi in property_service.py. Mark LIMIT-20 resolved in docs/KNOWN_LIMITATIONS.md. Full suite: 1803 passed. Ruff + mypy clean on touched files. --- .agents/logs/2026-07-31.jsonl | 1 + .../build-report-2026-07-31-finance-split.md | 54 + core/api_views.py | 8 +- core/services/__init__.py | 2 - core/services/brrrr.py | 8 +- core/services/cma.py | 9 +- core/services/market_scoring.py | 2 +- core/services/portfolio.py | 3 +- core/services/projections.py | 6 +- core/services/property_service.py | 23 - core/services/scoring.py | 13 +- core/tests/test_deal_analyzer.py | 14 +- core/tests/test_finance_utils.py | 12 +- docs/KNOWN_LIMITATIONS.md | 6 +- investor_app/finance/mortgage.py | 458 ++++ investor_app/finance/scoring.py | 150 ++ investor_app/finance/strategies.py | 518 ++++ investor_app/finance/taxes.py | 692 ++++++ investor_app/finance/utils.py | 2149 +---------------- tests/test_brrrr.py | 2 +- tests/test_finance_math.py | 15 +- tests/test_finance_utils.py | 165 +- tests/test_hold_period.py | 2 +- tests/test_market_scoring.py | 2 +- tests/test_property_service.py | 45 - tests/test_tax_analysis.py | 8 +- tests/test_underwriting_score.py | 172 +- tests_bdd/steps/test_property_analysis.py | 8 +- 28 files changed, 1946 insertions(+), 2601 deletions(-) create mode 100644 .agents/logs/2026-07-31.jsonl create mode 100644 .agents/reports/build-report-2026-07-31-finance-split.md create mode 100644 investor_app/finance/mortgage.py create mode 100644 investor_app/finance/scoring.py create mode 100644 investor_app/finance/strategies.py create mode 100644 investor_app/finance/taxes.py delete mode 100644 tests/test_property_service.py diff --git a/.agents/logs/2026-07-31.jsonl b/.agents/logs/2026-07-31.jsonl new file mode 100644 index 00000000..f1e278d3 --- /dev/null +++ b/.agents/logs/2026-07-31.jsonl @@ -0,0 +1 @@ +{"agent": "build", "session_id": "build-20260731-finance-split-001", "triggered_by": "feature-flow", "started_at": "2026-07-31T16:00:00Z", "timestamp": "2026-07-31T17:45:00Z", "duration_ms": 6300000, "skills_loaded": ["code-generation", "refactoring", "template-application"], "findings": [{"id": "FIND-001", "description": "Moved copy of total_return_summary dropped the purchase_price key from its return dict, breaking test_dict_keys_present — restored the key to match the original contract", "actionable": true, "manual_review_needed": false, "severity": "defect"}, {"id": "FIND-002", "description": "after_tax_irr in taxes.py had a local import of irr from utils that was unused (function reimplements npf.irr inline) — removed per ruff F401", "actionable": true, "manual_review_needed": false, "severity": "defect"}, {"id": "FIND-003", "description": "tests/test_underwriting_score.py still tested the deleted pure score_listing_v2 (audit finding #2); kept one_percent_rule/gross_rent_multiplier primitive tests, deleted TestScoreListingV2 since production score lives only in core/services/scoring.py (covered by core/tests/test_scoring_v2.py)", "actionable": true, "manual_review_needed": false, "severity": "defect"}, {"id": "FIND-004", "description": "Service-layer duplicate calculate_noi in core/services/property_service.py was exported but imported by no production code — deleted function, export, and its test file", "actionable": true, "manual_review_needed": false, "severity": "defect"}, {"id": "FIND-005", "description": "Dual-pipeline investigation: Django (PipelineAsset/PipelineProperty + core/services/pipeline.py) is the load-bearing pipeline; the pydantic prei FastAPI router, CLI, and orchestrator are not mounted in any Django URLconf/INSTALLED_APPS/docker-compose; only core/views/__init__.py couples to prei (get_state_landlord_score + lazy DiscoveryProcessor/BatchScreeningProcessor/discover_from_all for the Growth Explorer bridge). Full removal of pydantic state machine requires PM sign-off", "actionable": false, "manual_review_needed": true, "severity": "note"}, {"id": "FIND-006", "description": "Re-export backfill from utils.py was unnecessary: after updating all 17 importers, no remaining importer pulls a moved name from investor_app.finance.utils; keeping the monolith aliases would defeat the split", "actionable": false, "manual_review_needed": false, "severity": "note"}, {"id": "FIND-007", "description": "Pre-existing mypy error in tests/acceptance/conftest.py:69 (no-any-return) unrelated to this change — file unmodified", "actionable": false, "manual_review_needed": false, "severity": "note"}], "decision": "implemented", "blockers": [], "pr": null} diff --git a/.agents/reports/build-report-2026-07-31-finance-split.md b/.agents/reports/build-report-2026-07-31-finance-split.md new file mode 100644 index 00000000..60c31e04 --- /dev/null +++ b/.agents/reports/build-report-2026-07-31-finance-split.md @@ -0,0 +1,54 @@ +## Build Report — Finance utils split + audit finding resolution (LIMIT-20) + +**Status:** COMPLETE + +--- + +### Tasks Completed + +| Task | Title | Lines Changed | Status | +| -------- | ----- | ------------- | ------ | +| SPLIT-1 | Create `investor_app/finance/mortgage.py` (9 functions: mortgage, carrying costs, break-even rent, paydown, appreciation, ROI components) | ~330 | DONE | +| SPLIT-2 | Create `investor_app/finance/taxes.py` (11 functions: depreciation, tax benefits, after-tax IRR/CF, hold-period projections, sale proceeds, recapture) | ~640 | DONE | +| SPLIT-3 | Create `investor_app/finance/scoring.py` (5 primitives: 1% rule, GRM, price-to-rent, market normalization helpers) | ~150 | DONE | +| SPLIT-4 | Create `investor_app/finance/strategies.py` (8 functions: flip, rental, vacation, BRRRR calculators; `estimate_rehab_cost` decoupled from settings) | ~400 | DONE | +| SPLIT-5 | Rewrite `investor_app/finance/utils.py` to core math + Django-coupled analysis only; delete aliases (`calculate_noi`/`calculate_cap_rate`/`calculate_cash_on_cash`/`calculate_irr`), dead `score_listing_v1`, and deprecated `score_listing_v1_deprecated` chain | ~231 (net −2116) | DONE | +| IMPORTERS | Update 17 importers (services, api_views, views, tests) to canonical module locations | ~120 | DONE | +| AUDIT-1 | Remove dead service-layer duplicate `calculate_noi` from `core/services/property_service.py` (+ export in `__init__.py`, delete `tests/test_property_service.py`) | −45 | DONE | +| AUDIT-2 | Delete duplicate pure `score_listing_v2` tests (`TestScoreListingV2` in `tests/test_underwriting_score.py`); production version remains only in `core/services/scoring.py` | −161 | DONE | +| VERIFY | Fix behavior regression: `total_return_summary` must keep returning `purchase_price` key (moved copy dropped it) | +3 | DONE | +| DOCS | Mark LIMIT-20 resolved in `docs/KNOWN_LIMITATIONS.md` | +8 | DONE | + +### Artifacts Produced + +- [x] Source code files — `investor_app/finance/{mortgage,taxes,scoring,strategies}.py` +- [x] Source code files — rewritten `investor_app/finance/utils.py` +- [ ] Manifests in `manifests/` — N/A (no K8s surface touched) +- [ ] Pipeline in `pipeline-spec.yaml` — N/A (no CI pipeline change) +- [ ] Overlays in `overlays/` — N/A (no GitOps change) + +### Validation Results + +| Check | Status | +| --------- | ------ | +| Lint (ruff) | PASS | +| Typecheck (mypy, touched files) | PASS | +| Tests (full suite) | PASS — 1803 passed, 1 skipped, 261 deselected | +| Policy | PASS — no governance violations; `postgres`, `migration-safety`, `gitops` untouched | + +Pre-existing mypy error in `tests/acceptance/conftest.py:69` (no-any-return) is unrelated to this change — file unmodified. + +### Blockers + +None. + +### Dual-pipeline (pydantic `prei` vs Django) findings + +Per the user's directive to resolve the dual-pipeline question "using Django," investigated how much production code depends on the pydantic `prei` side: + +- **Django is the load-bearing pipeline**: `core/models/pipeline.py` (`PipelineAsset`, `PipelineProperty`), `core/services/pipeline.py`, screening, leasing, notifications, and ~20 prod/test files. This is the source of truth. +- **`prei` pydantic side is a standalone FastAPI microservice, not mounted**: `prei/api/pipeline_routes.py` (FastAPI router) and `prei/cli.py` are not referenced by any Django URLconf, `INSTALLED_APPS`, docker-compose service, or CI deploy. `prei/pipeline/orchestrator.py` is imported only by tests. +- **One production coupling**: `core/views/__init__.py` imports `prei.integrations.landlord_data.get_state_landlord_score` (top-level) and lazily imports `DiscoveryProcessor`/`BatchScreeningProcessor`/`ScreeningThresholds`/`PipelineEngine`/`InMemoryAssetRepository`/`discover_from_all` for the Growth Explorer bridge (P0 fix from `docs/assessments/AUDIT_GA_PIPELINE.md`). +- **Recommendation**: keep the pydantic discovery/screening *processors* (they are the only working bridge from Growth Explorer into pipeline screening, and they are Decimal-based after Phase B) but do not build new state on pydantic models (`PropertyAsset`/`StageLog`); persist pipeline state via Django `PipelineProperty`/`PipelineAsset`. Full removal of the pydantic state machine, FastAPI routes, and CLI is a separate reviewed change requiring PM sign-off — it touches the orchestrator, handlers, 11 test files, and the Growth Explorer bridge. Filed as a follow-up recommendation, not executed here. + +The finance-utils split itself is independent of that decision: `prei/pipeline/{orchestrator,handlers/underwriting}.py` still import only `to_decimal`/`cap_rate`/`cash_on_cash` from `investor_app.finance.utils`, all of which remain in place. diff --git a/core/api_views.py b/core/api_views.py index a963a289..baacb275 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -26,13 +26,17 @@ from .models import VrmProperty from .serializers import VrmPropertySerializer -from investor_app.finance.utils import ( +from investor_app.finance.mortgage import ( calculate_break_even_rent, calculate_carrying_costs as calc_costs, + calculate_roi_components, +) +from investor_app.finance.strategies import ( calculate_flip_strategy, calculate_rental_strategy, - calculate_roi_components, calculate_vacation_rental_strategy, +) +from investor_app.finance.utils import ( cap_rate as calc_cap_rate, cash_on_cash as calc_coc, compute_analysis_for_property, diff --git a/core/services/__init__.py b/core/services/__init__.py index 55971a3a..5c16166d 100644 --- a/core/services/__init__.py +++ b/core/services/__init__.py @@ -2,14 +2,12 @@ from core.services.portfolio import compute_portfolio_summary from core.services.property_service import ( - calculate_noi, compute_noi, compute_noi_for_user, compute_noi_from_amounts, ) __all__ = [ - "calculate_noi", "compute_noi", "compute_noi_for_user", "compute_noi_from_amounts", diff --git a/core/services/brrrr.py b/core/services/brrrr.py index 5c1d8e24..d6f605e2 100644 --- a/core/services/brrrr.py +++ b/core/services/brrrr.py @@ -34,17 +34,15 @@ from django.conf import settings from core.models import Listing -from investor_app.finance.utils import ( +from investor_app.finance.mortgage import calculate_monthly_mortgage +from investor_app.finance.strategies import ( brrrr_coc_return, - calculate_monthly_mortgage, cash_left_in_deal, - dscr, estimate_arv, estimate_rehab_cost, max_refinance_loan, - noi, - to_decimal, ) +from investor_app.finance.utils import dscr, noi, to_decimal logger = logging.getLogger(__name__) diff --git a/core/services/cma.py b/core/services/cma.py index e59543e5..fcfa731d 100644 --- a/core/services/cma.py +++ b/core/services/cma.py @@ -7,13 +7,8 @@ from django.conf import settings from core.models import Listing, MarketSnapshot -from investor_app.finance.utils import ( - calculate_monthly_mortgage, - cap_rate, - cash_on_cash, - dscr, - noi, -) +from investor_app.finance.mortgage import calculate_monthly_mortgage +from investor_app.finance.utils import cap_rate, cash_on_cash, dscr, noi logger = logging.getLogger(__name__) diff --git a/core/services/market_scoring.py b/core/services/market_scoring.py index 892a6228..2e6836ec 100644 --- a/core/services/market_scoring.py +++ b/core/services/market_scoring.py @@ -85,7 +85,7 @@ def _score_market_from_snapshot(snapshot) -> Decimal: Returns a Decimal in [0, 100] computed as a weighted average of normalised sub-scores for each available signal. """ - from investor_app.finance.utils import ( + from investor_app.finance.scoring import ( clamp_market_score, normalize_market_growth_rate_score, normalize_market_price_to_rent_score, diff --git a/core/services/portfolio.py b/core/services/portfolio.py index 23e1b63e..cdbde8e3 100644 --- a/core/services/portfolio.py +++ b/core/services/portfolio.py @@ -359,7 +359,8 @@ def calculate_ytd_cashflow( def _get_annual_debt_service(property_obj: Property) -> Decimal: """Calculate annual debt service for a property.""" - from investor_app.finance.utils import calculate_monthly_mortgage, to_decimal + from investor_app.finance.mortgage import calculate_monthly_mortgage + from investor_app.finance.utils import to_decimal loan_amount = to_decimal(property_obj.purchase_price) * ( Decimal("1") - to_decimal(property_obj.down_payment_pct) diff --git a/core/services/projections.py b/core/services/projections.py index ea8857ce..d771b192 100644 --- a/core/services/projections.py +++ b/core/services/projections.py @@ -10,12 +10,12 @@ import numpy as np import numpy_financial as npf -from investor_app.finance.utils import ( +from investor_app.finance.mortgage import calculate_monthly_mortgage +from investor_app.finance.taxes import ( calculate_after_tax_cashflow, calculate_annual_depreciation, - calculate_monthly_mortgage, - to_decimal, ) +from investor_app.finance.utils import to_decimal if TYPE_CHECKING: from core.models import Property diff --git a/core/services/property_service.py b/core/services/property_service.py index 7b4d6a34..a005a8b4 100644 --- a/core/services/property_service.py +++ b/core/services/property_service.py @@ -130,26 +130,3 @@ def compute_noi_from_amounts( Annual NOI as a ``Decimal``, quantized to two decimal places. """ return noi(monthly_income, monthly_expenses).quantize(Decimal("0.01")) - - -def calculate_noi( - gross_income: Decimal, - operating_expenses: Decimal, -) -> Decimal: - """Calculate annual Net Operating Income (NOI). - - NOI = Gross Income - Operating Expenses - - This is a pure service-layer function that delegates to the finance - utility layer. It does not touch the database. - - Args: - gross_income: Total annual gross income from the property. - operating_expenses: Total annual operating expenses (excluding debt service). - - Returns: - Annual NOI as a ``Decimal``, quantized to two decimal places. - """ - return (to_decimal(gross_income) - to_decimal(operating_expenses)).quantize( - Decimal("0.01") - ) diff --git a/core/services/scoring.py b/core/services/scoring.py index dc49890f..52bebb92 100644 --- a/core/services/scoring.py +++ b/core/services/scoring.py @@ -130,12 +130,11 @@ def score_listing_v2(property_obj, targets) -> UnderwritingScore: Returns: UnderwritingScore with all fields populated. """ - from investor_app.finance.utils import ( - build_cashflows, + from investor_app.finance.taxes import ( calculate_annual_depreciation, calculate_after_tax_cashflow, - irr as calc_irr, ) + from investor_app.finance.utils import build_cashflows, irr as calc_irr from core.models import UserProfile pp = property_obj.purchase_price @@ -167,12 +166,8 @@ def score_listing_v2(property_obj, targets) -> UnderwritingScore: total_expenses = opex + mgmt_fee annual_noi = effective_rent - total_expenses # KPIs using utils functions - from investor_app.finance.utils import ( - cap_rate as calc_cap_rate, - cash_on_cash, - dscr, - gross_rent_multiplier, - ) + from investor_app.finance.scoring import gross_rent_multiplier + from investor_app.finance.utils import cap_rate as calc_cap_rate, cash_on_cash, dscr cap = calc_cap_rate(annual_noi, pp) grm = gross_rent_multiplier(pp, annual_rent) if annual_rent > 0 else Decimal("999") diff --git a/core/tests/test_deal_analyzer.py b/core/tests/test_deal_analyzer.py index fa0d70cf..00b660f4 100644 --- a/core/tests/test_deal_analyzer.py +++ b/core/tests/test_deal_analyzer.py @@ -8,18 +8,16 @@ import pytest -from investor_app.finance.utils import ( +from investor_app.finance.mortgage import ( calculate_break_even_rent, - calculate_flip_strategy, calculate_monthly_mortgage, - calculate_rental_strategy, calculate_roi_components, - cap_rate, - cash_on_cash, - dscr, - irr, - noi, ) +from investor_app.finance.strategies import ( + calculate_flip_strategy, + calculate_rental_strategy, +) +from investor_app.finance.utils import cap_rate, cash_on_cash, dscr, irr, noi # --------------------------------------------------------------------------- # noi diff --git a/core/tests/test_finance_utils.py b/core/tests/test_finance_utils.py index 0d252c9c..ce7762e8 100644 --- a/core/tests/test_finance_utils.py +++ b/core/tests/test_finance_utils.py @@ -3,8 +3,7 @@ import pytest -from investor_app.finance.utils import ( - build_cashflows, +from investor_app.finance.mortgage import ( calculate_appreciation, calculate_break_even_rent, calculate_carrying_costs, @@ -13,12 +12,15 @@ calculate_principal_paydown, calculate_property_tax, calculate_roi_components, - calculate_tax_benefits, + estimate_insurance, +) +from investor_app.finance.taxes import calculate_tax_benefits +from investor_app.finance.utils import ( + build_cashflows, cap_rate, cash_on_cash, - dscr, - estimate_insurance, compute_analysis_for_property, + dscr, irr, noi, ) diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index aeb8f01c..60361246 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -212,15 +212,15 @@ This means a user who runs the API pre-`populate_growth_areas` gets empty result --- -### [LIMIT-20] 🟡 HIGH — Divergent bare-function vs. `calculate_*` contracts for the same formulas, plus a duplicate `score_listing_v2` +### [LIMIT-20] 🟡 HIGH — Divergent bare-function vs. `calculate_*` contracts for the same formulas, plus a duplicate `score_listing_v2` (resolved) **Location:** `investor_app/finance/utils.py` — `noi`/`cap_rate`/`cash_on_cash`/`dscr` vs. `calculate_noi`/`calculate_cap_rate`/`calculate_cash_on_cash`/`calculate_irr`; `investor_app/finance/utils.py:1830` vs. `core/services/scoring.py:110` (`score_listing_v2`). **Impact:** Two independent implementations exist for the same four KPI formulas — the bare functions return `Decimal("0")` on invalid input (e.g. zero purchase price, zero debt service), while the `calculate_*` variants raise `ValueError` under the same conditions. Callers that reach for the "wrong" variant get silently different failure behavior for identical bad input, and there is no single source of truth to point developers at. Separately, two functions both named `score_listing_v2` exist with different signatures — one in `investor_app/finance/utils.py`, one in `core/services/scoring.py` — an accident waiting to cause a wrong-function-imported bug. -**Workaround:** None currently. Callers must know which variant (bare vs. `calculate_*`) they're calling and its error-handling contract; `score_listing_v2` callers must be careful to import from the intended module. +**Workaround:** Resolved by the finance-utils split. The `calculate_*` aliases (`calculate_noi`, `calculate_cap_rate`, `calculate_cash_on_cash`, `calculate_irr`), the dead `score_listing_v1` chain, and the duplicate pure `score_listing_v2` were deleted. The bare functions (`noi`, `cap_rate`, `cash_on_cash`, `dscr`, `irr`) are now the single source of truth in `investor_app/finance/utils.py`, with the production underwriting score living only in `core/services/scoring.py`. The service-layer duplicate `calculate_noi` in `core/services/property_service.py` was also removed. -**Fix tracked in:** Not yet filed. Found during Phase B (docs/TOP_01_PLAN.md) financial-math audit; out of scope for that PR since it requires an API-contract decision (which behavior is canonical) rather than a mechanical fix. +**Fix tracked in:** Resolved in the finance-utils split PR (finance package reorganized into `mortgage`, `taxes`, `scoring`, `strategies` submodules). --- diff --git a/investor_app/finance/mortgage.py b/investor_app/finance/mortgage.py new file mode 100644 index 00000000..00ed418b --- /dev/null +++ b/investor_app/finance/mortgage.py @@ -0,0 +1,458 @@ +"""Mortgage, carrying costs, break-even rent, and ROI component calculations.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +import logging +from typing import Any, Dict + +from investor_app.finance.utils import to_decimal + +logger = logging.getLogger(__name__) + + +def calculate_monthly_mortgage( + loan_amount: Decimal, interest_rate: Decimal, loan_term_years: int +) -> Decimal: + """Calculate monthly mortgage payment (principal and interest). + + Args: + loan_amount: Total loan amount + interest_rate: Annual interest rate as percentage (e.g., 7.5 for 7.5%) + loan_term_years: Loan term in years + + Returns: + Monthly payment amount + """ + loan_amt = to_decimal(loan_amount) + rate = to_decimal(interest_rate) + + if loan_amt == 0: + return Decimal("0") + + if rate == 0: + # No interest - simple division + return loan_amt / Decimal(loan_term_years * 12) + + monthly_rate = rate / Decimal(100) / Decimal(12) + num_payments = Decimal(loan_term_years * 12) + + # Standard amortization formula: M = P[r(1+r)^n]/[(1+r)^n-1] + factor = (Decimal(1) + monthly_rate) ** num_payments + monthly_payment = loan_amt * (monthly_rate * factor) / (factor - Decimal(1)) + + return monthly_payment.quantize(Decimal("0.01")) + + +def calculate_property_tax( + property_value: Decimal, tax_rate_percent: Decimal +) -> Decimal: + """Calculate annual property tax. + + Args: + property_value: Property value/assessed value + tax_rate_percent: Property tax rate as percentage (e.g., 2.1 for 2.1%) + + Returns: + Annual property tax amount + """ + return ( + to_decimal(property_value) * to_decimal(tax_rate_percent) / Decimal(100) + ).quantize(Decimal("0.01")) + + +def estimate_insurance( + property_value: Decimal, + property_type: str = "single-family", + year_built: int = 2000, +) -> Decimal: + """Estimate annual insurance cost. + + Args: + property_value: Property value + property_type: Type of property (single-family, condo, multi-family) + year_built: Year property was built + + Returns: + Estimated annual insurance premium + """ + base_rate = Decimal("1200") # National average for $250k home + + # Adjust for property value + value_factor = to_decimal(property_value) / Decimal("250000") + + # Adjust for property type + type_factors = { + "single-family": Decimal("1.0"), + "condo": Decimal("0.7"), + "multi-family": Decimal("1.3"), + "commercial": Decimal("1.5"), + } + type_factor = type_factors.get(property_type, Decimal("1.0")) + + # Adjust for age + current_year = datetime.now().year + age = max(0, current_year - year_built) + age_factor = Decimal("1.0") + (Decimal(age) / Decimal(50)) + + annual_insurance = base_rate * value_factor * type_factor * age_factor + return annual_insurance.quantize(Decimal("0.01")) + + +def calculate_maintenance_reserve( + property_value: Decimal, + year_built: int = 2000, + annual_percent: Decimal = Decimal("1.0"), +) -> Decimal: + """Calculate annual maintenance reserve (1% rule with age adjustment). + + Args: + property_value: Property value + year_built: Year property was built + annual_percent: Base annual percentage of property value (default 1%) + + Returns: + Annual maintenance reserve amount + """ + base_maintenance = ( + to_decimal(property_value) * to_decimal(annual_percent) / Decimal(100) + ) + + # Adjust for age + if year_built < 1980: + age_factor = Decimal("1.5") + elif year_built < 2000: + age_factor = Decimal("1.2") + else: + age_factor = Decimal("1.0") + + return (base_maintenance * age_factor).quantize(Decimal("0.01")) + + +def calculate_break_even_rent( + monthly_carrying_costs: Decimal, + vacancy_rate_percent: Decimal, + property_management_percent: Decimal = Decimal("10"), +) -> Dict[str, Decimal]: + """Calculate break-even rent needed to cover carrying costs. + + Args: + monthly_carrying_costs: Total monthly carrying costs (excluding property management) + vacancy_rate_percent: Vacancy rate as percentage (e.g., 8 for 8%) + property_management_percent: Property management fee as percentage of rent + + Returns: + Dictionary with breakEvenRent and related metrics + """ + costs = to_decimal(monthly_carrying_costs) + vacancy = to_decimal(vacancy_rate_percent) / Decimal(100) + mgmt = to_decimal(property_management_percent) / Decimal(100) + + # Formula: rent * (1 - vacancy) * (1 - mgmt) = costs + # rent = costs / ((1 - vacancy) * (1 - mgmt)) + divisor = (Decimal(1) - vacancy) * (Decimal(1) - mgmt) + if divisor == 0: + return { + "monthly": Decimal("0"), + "annual": Decimal("0"), + } + break_even = costs / divisor + + return { + "monthly": break_even.quantize(Decimal("0.01")), + "annual": (break_even * Decimal(12)).quantize(Decimal("0.01")), + } + + +def calculate_carrying_costs( + purchase_price: Decimal, + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + property_tax_rate: Decimal, + insurance_annual: Decimal | None = None, + hoa_monthly: Decimal = Decimal("0"), + utilities_monthly: Decimal = Decimal("0"), + maintenance_annual_percent: Decimal = Decimal("1.0"), + property_type: str = "single-family", + year_built: int = 2000, +) -> Dict[str, Any]: + """Calculate complete carrying costs breakdown. + + Args: + purchase_price: Property purchase price + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + property_tax_rate: Property tax rate as percentage + insurance_annual: Annual insurance cost (if None, will estimate) + hoa_monthly: Monthly HOA fees + utilities_monthly: Monthly utility costs + maintenance_annual_percent: Maintenance as percentage of property value + property_type: Type of property + year_built: Year property was built + + Returns: + Dictionary with detailed carrying cost breakdown + """ + # Calculate mortgage + monthly_mortgage = calculate_monthly_mortgage( + loan_amount, interest_rate, loan_term_years + ) + + # Calculate property tax + annual_property_tax = calculate_property_tax(purchase_price, property_tax_rate) + monthly_property_tax = annual_property_tax / Decimal(12) + + # Calculate or use provided insurance + if insurance_annual is None: + annual_insurance = estimate_insurance(purchase_price, property_type, year_built) + else: + annual_insurance = to_decimal(insurance_annual) + monthly_insurance = annual_insurance / Decimal(12) + + # Calculate maintenance + annual_maintenance = calculate_maintenance_reserve( + purchase_price, year_built, maintenance_annual_percent + ) + monthly_maintenance = annual_maintenance / Decimal(12) + + # Monthly costs + monthly_hoa = to_decimal(hoa_monthly) + monthly_utilities = to_decimal(utilities_monthly) + + # Calculate totals + monthly_total = ( + monthly_mortgage + + monthly_property_tax + + monthly_insurance + + monthly_hoa + + monthly_utilities + + monthly_maintenance + ) + + annual_total = monthly_total * Decimal(12) + + return { + "monthly": { + "mortgage": monthly_mortgage.quantize(Decimal("0.01")), + "propertyTax": monthly_property_tax.quantize(Decimal("0.01")), + "insurance": monthly_insurance.quantize(Decimal("0.01")), + "hoa": monthly_hoa.quantize(Decimal("0.01")), + "utilities": monthly_utilities.quantize(Decimal("0.01")), + "maintenance": monthly_maintenance.quantize(Decimal("0.01")), + "total": monthly_total.quantize(Decimal("0.01")), + }, + "annual": { + "mortgage": (monthly_mortgage * Decimal(12)).quantize(Decimal("0.01")), + "propertyTax": annual_property_tax.quantize(Decimal("0.01")), + "insurance": annual_insurance.quantize(Decimal("0.01")), + "hoa": (monthly_hoa * Decimal(12)).quantize(Decimal("0.01")), + "utilities": (monthly_utilities * Decimal(12)).quantize(Decimal("0.01")), + "maintenance": annual_maintenance.quantize(Decimal("0.01")), + "total": annual_total.quantize(Decimal("0.01")), + }, + } + + +def calculate_principal_paydown( + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + num_years: int = 1, +) -> Decimal: + """Calculate total principal paid down over specified number of years. + + Args: + loan_amount: Initial loan amount + interest_rate: Annual interest rate as percentage (e.g., 7.5 for 7.5%) + loan_term_years: Total loan term in years + num_years: Number of years to calculate paydown for (default 1) + + Returns: + Total principal paid down over the specified period + """ + if loan_amount == 0 or num_years == 0: + return Decimal("0") + + loan_amt = to_decimal(loan_amount) + rate = to_decimal(interest_rate) + + if rate == 0: + # No interest - equal principal payments + monthly_principal = loan_amt / Decimal(loan_term_years * 12) + return monthly_principal * Decimal(num_years * 12) + + monthly_rate = rate / Decimal(100) / Decimal(12) + monthly_payment = calculate_monthly_mortgage( + loan_amount, interest_rate, loan_term_years + ) + + # Calculate principal paid by simulating each payment + remaining_balance = loan_amt + total_principal_paid = Decimal("0") + + for month in range(num_years * 12): + interest_payment = remaining_balance * monthly_rate + principal_payment = monthly_payment - interest_payment + total_principal_paid += principal_payment + remaining_balance -= principal_payment + + if remaining_balance <= 0: + break + + return total_principal_paid.quantize(Decimal("0.01")) + + +def calculate_appreciation( + property_value: Decimal, + appreciation_rate: Decimal, + num_years: int = 1, +) -> Decimal: + """Calculate property appreciation over specified number of years. + + Args: + property_value: Current property value + appreciation_rate: Annual appreciation rate as percentage (e.g., 3.0 for 3%) + num_years: Number of years to project (default 1) + + Returns: + Total appreciation amount + """ + value = to_decimal(property_value) + rate = to_decimal(appreciation_rate) / Decimal(100) + + future_value = value * ((Decimal(1) + rate) ** Decimal(num_years)) + appreciation = future_value - value + + return appreciation.quantize(Decimal("0.01")) + + +def calculate_roi_components( + purchase_price: Decimal, + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + total_cash_invested: Decimal, + annual_cash_flow: Decimal, + appreciation_rate: Decimal = Decimal("3.0"), + tax_bracket: Decimal = Decimal("24"), + num_years: int = 5, +) -> Dict[str, Any]: + """Calculate comprehensive ROI with all components over multiple years. + + Args: + purchase_price: Property purchase price + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + total_cash_invested: Total cash invested (down payment + closing costs) + annual_cash_flow: Annual pre-tax cash flow + appreciation_rate: Annual appreciation rate as percentage (default 3%) + tax_bracket: Marginal tax bracket as percentage (default 24%) + num_years: Number of years to project (default 5) + + Returns: + Dictionary with ROI components and projections + """ + from investor_app.finance.taxes import calculate_tax_benefits + + # Year 1 calculations + year1_cash_flow = to_decimal(annual_cash_flow) + year1_principal_paydown = calculate_principal_paydown( + loan_amount, interest_rate, loan_term_years, 1 + ) + year1_appreciation = calculate_appreciation(purchase_price, appreciation_rate, 1) + year1_tax_benefits = calculate_tax_benefits( + loan_amount, interest_rate, loan_term_years, purchase_price, tax_bracket, 1 + ) + + year1_total_return = ( + year1_cash_flow + + year1_principal_paydown + + year1_appreciation + + year1_tax_benefits + ) + + if total_cash_invested > 0: + year1_roi = year1_total_return / to_decimal(total_cash_invested) * Decimal(100) + else: + year1_roi = Decimal("0") + + # Multi-year calculations + total_cash_flow = year1_cash_flow * Decimal( + num_years + ) # Simplified: assumes constant + total_principal_paydown = calculate_principal_paydown( + loan_amount, interest_rate, loan_term_years, num_years + ) + total_appreciation = calculate_appreciation( + purchase_price, appreciation_rate, num_years + ) + + # Sum tax benefits for each year + total_tax_benefits = Decimal("0") + for year in range(1, num_years + 1): + total_tax_benefits += calculate_tax_benefits( + loan_amount, + interest_rate, + loan_term_years, + purchase_price, + tax_bracket, + year, + ) + + total_return = ( + total_cash_flow + + total_principal_paydown + + total_appreciation + + total_tax_benefits + ) + + if total_cash_invested > 0: + multi_year_roi = total_return / to_decimal(total_cash_invested) * Decimal(100) + # Annualized return + annualized_roi = ( + (Decimal(1) + multi_year_roi / Decimal(100)) + ** (Decimal(1) / Decimal(num_years)) + - Decimal(1) + ) * Decimal(100) + else: + multi_year_roi = Decimal("0") + annualized_roi = Decimal("0") + + # Component percentages for year 1 + if year1_total_return > 0: + cash_flow_pct = year1_cash_flow / year1_total_return * Decimal(100) + appreciation_pct = year1_appreciation / year1_total_return * Decimal(100) + equity_pct = year1_principal_paydown / year1_total_return * Decimal(100) + tax_pct = year1_tax_benefits / year1_total_return * Decimal(100) + else: + cash_flow_pct = appreciation_pct = equity_pct = tax_pct = Decimal("0") + + return { + "year1": { + "roi": year1_roi.quantize(Decimal("0.1")), + "totalReturn": year1_total_return.quantize(Decimal("0.01")), + "cashFlow": year1_cash_flow.quantize(Decimal("0.01")), + "principalPaydown": year1_principal_paydown.quantize(Decimal("0.01")), + "appreciation": year1_appreciation.quantize(Decimal("0.01")), + "taxBenefits": year1_tax_benefits.quantize(Decimal("0.01")), + }, + f"year{num_years}Projected": { + "roi": multi_year_roi.quantize(Decimal("0.1")), + "annualizedRoi": annualized_roi.quantize(Decimal("0.1")), + "totalReturn": total_return.quantize(Decimal("0.01")), + "totalCashFlow": total_cash_flow.quantize(Decimal("0.01")), + "totalPrincipalPaydown": total_principal_paydown.quantize(Decimal("0.01")), + "totalAppreciation": total_appreciation.quantize(Decimal("0.01")), + "totalTaxBenefits": total_tax_benefits.quantize(Decimal("0.01")), + }, + "components": { + "cashFlowReturn": cash_flow_pct.quantize(Decimal("0.1")), + "appreciationReturn": appreciation_pct.quantize(Decimal("0.1")), + "equityBuildupReturn": equity_pct.quantize(Decimal("0.1")), + "taxBenefitsReturn": tax_pct.quantize(Decimal("0.1")), + }, + } diff --git a/investor_app/finance/scoring.py b/investor_app/finance/scoring.py new file mode 100644 index 00000000..277e8dfe --- /dev/null +++ b/investor_app/finance/scoring.py @@ -0,0 +1,150 @@ +"""Market scoring primitives: 1% rule, GRM, price-to-rent, normalization helpers. + +The underwriting score itself lives in ``core.services.scoring`` (Django-coupled). +This module holds the pure market/listing primitives shared across services. +""" + +from __future__ import annotations + +from decimal import Decimal +import logging + +from investor_app.finance.utils import to_decimal + +logger = logging.getLogger(__name__) + + +def one_percent_rule(monthly_rent: Decimal, purchase_price: Decimal) -> bool: + """Evaluate the 1% Rule for a rental property. + + The 1% Rule is a quick pass/fail filter: monthly rent should be at least + 1% of the purchase price to indicate a potentially viable rental investment. + + Args: + monthly_rent: Expected gross monthly rental income. + purchase_price: Total purchase price of the property. + + Returns: + True if monthly_rent / purchase_price >= 0.01, False otherwise. + + Raises: + ValueError: If purchase_price is zero or negative. + """ + pp = to_decimal(purchase_price) + if pp <= Decimal("0"): + raise ValueError( + f"purchase_price must be greater than zero (received {purchase_price})" + ) + return to_decimal(monthly_rent) / pp >= Decimal("0.01") + + +def gross_rent_multiplier(purchase_price: Decimal, annual_rent: Decimal) -> Decimal: + """Calculate Gross Rent Multiplier (GRM). + + GRM = Purchase Price / Annual Rent + + Lower GRM values indicate better value relative to rental income. + + Args: + purchase_price: Total purchase price of the property. + annual_rent: Expected gross annual rental income. + + Returns: + GRM as a Decimal. + + Raises: + ValueError: If annual_rent is zero or negative. + """ + ar = to_decimal(annual_rent) + if ar <= Decimal("0"): + raise ValueError( + f"annual_rent must be greater than zero (received {annual_rent})" + ) + return to_decimal(purchase_price) / ar + + +def price_to_rent_ratio( + median_home_price: Decimal, annual_median_rent: Decimal +) -> Decimal: + """Calculate market price-to-rent ratio. + + Args: + median_home_price: Median home purchase price. + annual_median_rent: Median annual rent. + + Returns: + Price-to-rent ratio as a Decimal. + + Raises: + ValueError: If annual_median_rent is zero or negative. + """ + annual_rent = to_decimal(annual_median_rent) + if annual_rent <= Decimal("0"): + raise ValueError( + "annual_median_rent must be greater than zero " + f"(received {annual_median_rent})" + ) + return to_decimal(median_home_price) / annual_rent + + +_EXCELLENT_PRICE_TO_RENT_THRESHOLD = Decimal("15") +_NEUTRAL_PRICE_TO_RENT_THRESHOLD = Decimal("20") +_MAX_PRICE_TO_RENT_THRESHOLD = Decimal("30") +_HIGH_SCORE_FLOOR = Decimal("60") +_HIGH_SCORE_RANGE = Decimal("40") +_LOW_SCORE_RANGE = Decimal("60") + +_MIN_GROWTH_RATE_PERCENT = Decimal("-5") +_MAX_GROWTH_RATE_PERCENT = Decimal("10") +_GROWTH_RATE_RANGE = _MAX_GROWTH_RATE_PERCENT - _MIN_GROWTH_RATE_PERCENT + + +def normalize_market_price_to_rent_score(price_to_rent: Decimal) -> Decimal: + """Convert price-to-rent ratio into a 0-100 market sub-score. + + Args: + price_to_rent: Price-to-rent ratio for a market. + + Returns: + Market sub-score in [0, 100], where higher is better. + """ + if price_to_rent <= Decimal("0"): + return Decimal("0") + if price_to_rent < _EXCELLENT_PRICE_TO_RENT_THRESHOLD: + return Decimal("100") + if price_to_rent <= _NEUTRAL_PRICE_TO_RENT_THRESHOLD: + return (_NEUTRAL_PRICE_TO_RENT_THRESHOLD - price_to_rent) / ( + _NEUTRAL_PRICE_TO_RENT_THRESHOLD - _EXCELLENT_PRICE_TO_RENT_THRESHOLD + ) * _HIGH_SCORE_RANGE + _HIGH_SCORE_FLOOR + if price_to_rent <= _MAX_PRICE_TO_RENT_THRESHOLD: + return ( + (_MAX_PRICE_TO_RENT_THRESHOLD - price_to_rent) + / (_MAX_PRICE_TO_RENT_THRESHOLD - _NEUTRAL_PRICE_TO_RENT_THRESHOLD) + * _LOW_SCORE_RANGE + ) + return Decimal("0") + + +def normalize_market_growth_rate_score(growth_rate: Decimal) -> Decimal: + """Convert annual growth rate percent into a 0-100 market sub-score. + + Args: + growth_rate: Annual growth rate as a percent value. + + Returns: + Market sub-score in [0, 100], where higher is better. + """ + clamped = max(_MIN_GROWTH_RATE_PERCENT, min(_MAX_GROWTH_RATE_PERCENT, growth_rate)) + return (clamped - _MIN_GROWTH_RATE_PERCENT) / _GROWTH_RATE_RANGE * Decimal("100") + + +def clamp_market_score(value: Decimal) -> Decimal: + """Clamp a market score to the valid 0-100 range. + + Args: + value: Raw market score value. + + Returns: + Score clamped to [0, 100]. + """ + return max(Decimal("0"), min(Decimal("100"), value)) diff --git a/investor_app/finance/strategies.py b/investor_app/finance/strategies.py new file mode 100644 index 00000000..d22bca69 --- /dev/null +++ b/investor_app/finance/strategies.py @@ -0,0 +1,518 @@ +"""Investment strategy calculations: fix-and-flip, buy-and-hold, vacation rental, BRRRR.""" + +from __future__ import annotations + +from decimal import Decimal +import logging +from statistics import median +from typing import Any, Dict + +from investor_app.finance.utils import to_decimal + +logger = logging.getLogger(__name__) + + +def calculate_flip_strategy( + purchase_price: Decimal, + renovation_costs: Decimal, + holding_period_months: int, + expected_sale_price: Decimal, + selling_costs: Decimal, + down_payment: Decimal, + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + closing_costs: Decimal, + property_tax_rate: Decimal, + insurance_annual: Decimal | None = None, + utilities_monthly: Decimal = Decimal("0"), + property_type: str = "single-family", + year_built: int = 2000, +) -> Dict[str, Any]: + """Calculate fix-and-flip strategy returns. + + Args: + purchase_price: Property purchase price + renovation_costs: Total renovation costs + holding_period_months: How long to hold before selling (3-6 months typical) + expected_sale_price: Expected sale price after renovation + selling_costs: Total selling costs (realtor fees, etc.) + down_payment: Down payment amount + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + closing_costs: Closing costs on purchase + property_tax_rate: Property tax rate as percentage + insurance_annual: Annual insurance cost + utilities_monthly: Monthly utility costs while vacant + property_type: Type of property + year_built: Year property was built + + Returns: + Dictionary with flip strategy analysis + """ + from investor_app.finance.mortgage import ( + calculate_monthly_mortgage, + calculate_principal_paydown, + calculate_property_tax, + estimate_insurance, + ) + + # Calculate holding costs for the period + monthly_mortgage = calculate_monthly_mortgage( + loan_amount, interest_rate, loan_term_years + ) + annual_property_tax = calculate_property_tax(purchase_price, property_tax_rate) + monthly_property_tax = annual_property_tax / Decimal(12) + + if insurance_annual is None: + annual_insurance = estimate_insurance(purchase_price, property_type, year_built) + else: + annual_insurance = to_decimal(insurance_annual) + monthly_insurance = annual_insurance / Decimal(12) + + monthly_holding_costs = ( + monthly_mortgage + + monthly_property_tax + + monthly_insurance + + to_decimal(utilities_monthly) + ) + + total_holding_costs = monthly_holding_costs * Decimal(holding_period_months) + + # Total investment + total_investment = ( + to_decimal(down_payment) + + to_decimal(closing_costs) + + to_decimal(renovation_costs) + ) + + # Calculate proceeds + gross_sale_proceeds = to_decimal(expected_sale_price) + net_sale_proceeds = gross_sale_proceeds - to_decimal(selling_costs) + + # Remaining loan balance after holding period + principal_paid = calculate_principal_paydown( + loan_amount, interest_rate, loan_term_years, holding_period_months // 12 + ) + remaining_loan = to_decimal(loan_amount) - principal_paid + + # Net profit + net_profit = ( + net_sale_proceeds - remaining_loan - total_holding_costs - total_investment + ) + + # ROI + if total_investment > 0: + roi_percent = net_profit / total_investment * Decimal(100) + # Annualized return + years = Decimal(holding_period_months) / Decimal(12) + if years > 0 and roi_percent > Decimal("-100"): + annualized_return = ( + (Decimal(1) + roi_percent / Decimal(100)) ** (Decimal(1) / years) + - Decimal(1) + ) * Decimal(100) + else: + annualized_return = Decimal("0") + else: + roi_percent = Decimal("0") + annualized_return = Decimal("0") + + return { + "totalInvestment": total_investment.quantize(Decimal("0.01")), + "holdingCosts": total_holding_costs.quantize(Decimal("0.01")), + "renovationCosts": to_decimal(renovation_costs).quantize(Decimal("0.01")), + "saleProceeds": gross_sale_proceeds.quantize(Decimal("0.01")), + "sellingCosts": to_decimal(selling_costs).quantize(Decimal("0.01")), + "netProfit": net_profit.quantize(Decimal("0.01")), + "roi": roi_percent.quantize(Decimal("0.1")), + "timeframe": f"{holding_period_months} months", + "annualizedReturn": annualized_return.quantize(Decimal("0.1")), + } + + +def calculate_rental_strategy( + purchase_price: Decimal, + down_payment: Decimal, + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + closing_costs: Decimal, + annual_cash_flow: Decimal, + appreciation_rate: Decimal = Decimal("3.0"), + holding_period_years: int = 5, +) -> Dict[str, Any]: + """Calculate buy-and-hold rental strategy returns. + + Args: + purchase_price: Property purchase price + down_payment: Down payment amount + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + closing_costs: Closing costs + annual_cash_flow: Annual cash flow (can be negative) + appreciation_rate: Annual appreciation rate as percentage + holding_period_years: How many years to hold + + Returns: + Dictionary with rental strategy analysis + """ + from investor_app.finance.mortgage import ( + calculate_appreciation, + calculate_principal_paydown, + ) + + total_investment = to_decimal(down_payment) + to_decimal(closing_costs) + + # Simplified: assume constant cash flow (in reality it would improve over time) + total_cash_flow = to_decimal(annual_cash_flow) * Decimal(holding_period_years) + + # Equity buildup from mortgage paydown + equity_buildup = calculate_principal_paydown( + loan_amount, interest_rate, loan_term_years, holding_period_years + ) + + # Appreciation + appreciation = calculate_appreciation( + purchase_price, appreciation_rate, holding_period_years + ) + + # Total gain + total_gain = total_cash_flow + equity_buildup + appreciation + + # ROI + if total_investment > 0: + roi_percent = total_gain / total_investment * Decimal(100) + annualized_return = ( + (Decimal(1) + roi_percent / Decimal(100)) + ** (Decimal(1) / Decimal(holding_period_years)) + - Decimal(1) + ) * Decimal(100) + else: + roi_percent = Decimal("0") + annualized_return = Decimal("0") + + return { + "totalInvestment": total_investment.quantize(Decimal("0.01")), + "year1CashFlow": to_decimal(annual_cash_flow).quantize(Decimal("0.01")), + f"year{holding_period_years}CashFlow": to_decimal(annual_cash_flow).quantize( + Decimal("0.01") + ), # Simplified + f"totalCashFlow{holding_period_years}Years": total_cash_flow.quantize( + Decimal("0.01") + ), + f"equityBuildup{holding_period_years}Years": equity_buildup.quantize( + Decimal("0.01") + ), + f"appreciation{holding_period_years}Years": appreciation.quantize( + Decimal("0.01") + ), + f"totalGain{holding_period_years}Years": total_gain.quantize(Decimal("0.01")), + "roi": roi_percent.quantize(Decimal("0.1")), + "timeframe": f"{holding_period_years} years", + "annualizedReturn": annualized_return.quantize(Decimal("0.1")), + } + + +def calculate_vacation_rental_strategy( + purchase_price: Decimal, + down_payment: Decimal, + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + closing_costs: Decimal, + avg_nightly_rate: Decimal, + avg_occupancy_rate: Decimal, # As percentage (e.g., 65 for 65%) + cleaning_fee_per_stay: Decimal, + monthly_operating_expenses: Decimal, + holding_period_years: int = 5, + avg_stay_length_nights: int = 3, # Typical vacation rental stay length +) -> Dict[str, Any]: + """Calculate vacation rental strategy returns. + + Args: + purchase_price: Property purchase price + down_payment: Down payment amount + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + closing_costs: Closing costs + avg_nightly_rate: Average nightly rental rate + avg_occupancy_rate: Average occupancy rate as percentage + cleaning_fee_per_stay: Cleaning fee per stay + monthly_operating_expenses: Monthly operating expenses + holding_period_years: How many years to hold + avg_stay_length_nights: Average length of stay in nights (default 3) + + Returns: + Dictionary with vacation rental strategy analysis + """ + from investor_app.finance.mortgage import ( + calculate_appreciation, + calculate_monthly_mortgage, + calculate_principal_paydown, + ) + + total_investment = to_decimal(down_payment) + to_decimal(closing_costs) + + # Calculate annual income + nights_per_year = Decimal(365) + occupied_nights = nights_per_year * to_decimal(avg_occupancy_rate) / Decimal(100) + + # Calculate number of stays based on average stay length + avg_stay_length = Decimal(avg_stay_length_nights) + num_stays = occupied_nights / avg_stay_length + + annual_rental_income = occupied_nights * to_decimal( + avg_nightly_rate + ) + num_stays * to_decimal(cleaning_fee_per_stay) + + # Annual expenses + monthly_mortgage = calculate_monthly_mortgage( + loan_amount, interest_rate, loan_term_years + ) + annual_debt_service = monthly_mortgage * Decimal(12) + annual_operating_expenses = to_decimal(monthly_operating_expenses) * Decimal(12) + + # Cash flow + annual_cash_flow = ( + annual_rental_income - annual_debt_service - annual_operating_expenses + ) + + # Calculate year 1 CoC + if total_investment > 0: + coc_return = annual_cash_flow / total_investment * Decimal(100) + else: + coc_return = Decimal("0") + + # 5-year projection (simplified) + total_cash_flow = annual_cash_flow * Decimal(holding_period_years) + + # Equity buildup + equity_buildup = calculate_principal_paydown( + loan_amount, interest_rate, loan_term_years, holding_period_years + ) + + # Appreciation (3% default) + appreciation = calculate_appreciation( + purchase_price, Decimal("3.0"), holding_period_years + ) + + total_gain = total_cash_flow + equity_buildup + appreciation + + if total_investment > 0: + roi_percent = total_gain / total_investment * Decimal(100) + annualized_return = ( + (Decimal(1) + roi_percent / Decimal(100)) + ** (Decimal(1) / Decimal(holding_period_years)) + - Decimal(1) + ) * Decimal(100) + else: + roi_percent = Decimal("0") + annualized_return = Decimal("0") + + return { + "totalInvestment": total_investment.quantize(Decimal("0.01")), + "avgMonthlyIncome": (annual_rental_income / Decimal(12)).quantize( + Decimal("0.01") + ), + "avgMonthlyExpenses": ( + (annual_debt_service + annual_operating_expenses) / Decimal(12) + ).quantize(Decimal("0.01")), + "netCashFlowYear1": annual_cash_flow.quantize(Decimal("0.01")), + "cocReturn": coc_return.quantize(Decimal("0.1")), + "roi": roi_percent.quantize(Decimal("0.1")), + "timeframe": f"{holding_period_years} years", + "annualizedReturn": annualized_return.quantize(Decimal("0.1")), + "seasonalityImpact": ( + "High - Occupancy varies by season" + if avg_occupancy_rate < 75 + else "Moderate" + ), + } + + +# ── BRRRR ────────────────────────────────────────────────────────────────────── + + +def estimate_arv( + comparable_sales: list[tuple[Decimal, Decimal]], + subject_sqft: Decimal, +) -> Decimal: + """Estimate After-Repair Value (ARV) from comparable sales. + + Args: + comparable_sales: List of ``(price, sqft)`` tuples, one per comparable + sale. Both ``price`` and ``sqft`` must be positive. + subject_sqft: Square footage of the subject property (must be > 0). + + Returns: + Estimated ARV as a Decimal. + + Raises: + ValueError: If ``comparable_sales`` is empty. + ValueError: If any comparable has ``price <= 0`` or ``sqft <= 0``. + ValueError: If ``subject_sqft <= 0``. + """ + if not comparable_sales: + raise ValueError("comparable_sales must not be empty") + + subject = to_decimal(subject_sqft) + if subject <= Decimal("0"): + raise ValueError( + f"subject_sqft must be greater than zero (received {subject_sqft})" + ) + + ppsf_values: list[Decimal] = [] + for idx, (price, sqft) in enumerate(comparable_sales): + p = to_decimal(price) + s = to_decimal(sqft) + if p <= Decimal("0"): + raise ValueError( + f"comparable_sales[{idx}]: price must be greater than zero (received {price})" + ) + if s <= Decimal("0"): + raise ValueError( + f"comparable_sales[{idx}]: sqft must be greater than zero (received {sqft})" + ) + ppsf_values.append(p / s) + + median_ppsf = to_decimal(median(ppsf_values)) + return median_ppsf * subject + + +def estimate_rehab_cost( + sqft: Decimal, + renovation_level: str, + cost_per_sqft: dict[str, Decimal], +) -> Decimal: + """Estimate total rehab cost for a property. + + Args: + sqft: Square footage of the property (must be > 0). + renovation_level: Scope of renovation. Must be one of the keys present + in ``cost_per_sqft`` (typically ``"cosmetic"``, ``"moderate"``, or + ``"full_gut"``). + cost_per_sqft: Mapping from renovation level to cost per square foot. + Supply ``settings.REHAB_COST_PER_SQFT`` from the service layer to + keep this function Django-free. + + Returns: + Estimated rehab cost as a Decimal. + + Raises: + ValueError: If ``renovation_level`` is not a key in ``cost_per_sqft``. + ValueError: If ``sqft <= 0``. + """ + valid_levels = set(cost_per_sqft.keys()) + if renovation_level not in valid_levels: + raise ValueError( + f"renovation_level must be one of {sorted(valid_levels)} " + f"(received {renovation_level!r})" + ) + s = to_decimal(sqft) + if s <= Decimal("0"): + raise ValueError(f"sqft must be greater than zero (received {sqft})") + + rate = to_decimal(cost_per_sqft[renovation_level]) + return rate * s + + +def max_refinance_loan( + arv: Decimal, + ltv_ratio: Decimal = Decimal("0.75"), +) -> Decimal: + """Calculate the maximum cash-out refinance loan amount at a given LTV. + + Args: + arv: After-Repair Value of the property (must be > 0). + ltv_ratio: Loan-to-value ratio expressed as a decimal strictly between + 0 and 1 (e.g., ``Decimal("0.75")`` for 75 %). + + Returns: + Maximum refinance loan amount as a Decimal. + + Raises: + ValueError: If ``arv <= 0``. + ValueError: If ``ltv_ratio`` is not strictly in ``(0, 1)``. + """ + a = to_decimal(arv) + ltv = to_decimal(ltv_ratio) + + if a <= Decimal("0"): + raise ValueError(f"arv must be greater than zero (received {arv})") + if ltv <= Decimal("0") or ltv >= Decimal("1"): + raise ValueError( + f"ltv_ratio must be strictly between 0 and 1 (received {ltv_ratio})" + ) + + return a * ltv + + +def cash_left_in_deal( + purchase_price: Decimal, + rehab_cost: Decimal, + cash_out_refi_amount: Decimal, + closing_costs: Decimal = Decimal("0"), +) -> Decimal: + """Calculate the investor's remaining cash deployed after a cash-out refinance. + + Formula:: + + cash_left = purchase_price + rehab_cost + closing_costs - cash_out_refi_amount + + A negative or zero result means the investor has recouped all invested capital + (the "infinite CoC" scenario in BRRRR terminology). + + Args: + purchase_price: Purchase price of the property. + rehab_cost: Total rehabilitation cost. + cash_out_refi_amount: Proceeds from the cash-out refinance. + closing_costs: Total closing costs (purchase + refi combined). Defaults + to ``Decimal("0")``. + + Returns: + Cash left in the deal as a Decimal. Negative or zero => infinite CoC. + """ + return ( + to_decimal(purchase_price) + + to_decimal(rehab_cost) + + to_decimal(closing_costs) + - to_decimal(cash_out_refi_amount) + ) + + +def brrrr_coc_return( + annual_net_cash_flow: Decimal, + cash_left_in_deal: Decimal, +) -> Decimal: + """Calculate Cash-on-Cash return for a BRRRR deal. + + Handles the "infinite CoC" scenario where the investor has recouped all + (or more than all) of their capital. + + Rules: + * ``cash_left_in_deal <= 0`` -> returns ``Decimal("Infinity")`` regardless + of cash flow (investor has no capital remaining in the deal). + * ``cash_left_in_deal > 0`` and ``annual_net_cash_flow == 0`` -> returns + ``Decimal("0")`` (no return on remaining capital). + * Otherwise -> returns ``annual_net_cash_flow / cash_left_in_deal``. + + Args: + annual_net_cash_flow: Annual after-debt-service cash flow (can be + negative for a losing deal). + cash_left_in_deal: Capital still deployed after the cash-out refi + (from ``cash_left_in_deal()``). + + Returns: + CoC return as a Decimal. ``Decimal("Infinity")`` signals infinite CoC. + """ + left = to_decimal(cash_left_in_deal) + flow = to_decimal(annual_net_cash_flow) + + if left <= Decimal("0"): + return Decimal("Infinity") + if flow == Decimal("0"): + return Decimal("0") + return flow / left diff --git a/investor_app/finance/taxes.py b/investor_app/finance/taxes.py new file mode 100644 index 00000000..4c59c973 --- /dev/null +++ b/investor_app/finance/taxes.py @@ -0,0 +1,692 @@ +"""Depreciation, tax benefits, hold-period projections, and exit/sale analysis.""" + +from __future__ import annotations + +from decimal import Decimal +import logging +from typing import Dict, Sequence + +import numpy as np +import numpy_financial as npf + +from investor_app.finance.utils import to_decimal + +logger = logging.getLogger(__name__) + + +def calculate_tax_benefits( + loan_amount: Decimal, + interest_rate: Decimal, + loan_term_years: int, + property_value: Decimal, + tax_bracket: Decimal = Decimal("24"), + year_num: int = 1, +) -> Decimal: + """Calculate tax benefits from mortgage interest deduction and depreciation. + + Args: + loan_amount: Mortgage loan amount + interest_rate: Annual interest rate as percentage + loan_term_years: Loan term in years + property_value: Property value (for depreciation calculation) + tax_bracket: Marginal tax bracket as percentage (default 24%) + year_num: Which year to calculate benefits for (default 1) + + Returns: + Total tax benefit amount for the specified year + """ + from investor_app.finance.mortgage import calculate_monthly_mortgage + + if loan_amount == 0: + # All cash - only depreciation benefit + # Residential property: 27.5 year straight-line depreciation on 80% of value + building_value = to_decimal(property_value) * Decimal("0.80") + annual_depreciation = building_value / Decimal("27.5") + tax_savings = annual_depreciation * (to_decimal(tax_bracket) / Decimal(100)) + return tax_savings.quantize(Decimal("0.01")) + + # Calculate interest paid in specific year + loan_amt = to_decimal(loan_amount) + rate = to_decimal(interest_rate) + monthly_rate = rate / Decimal(100) / Decimal(12) + monthly_payment = calculate_monthly_mortgage( + loan_amount, interest_rate, loan_term_years + ) + + # Calculate remaining balance at start of year + # Uses standard amortization formula: B = P * [(1+r)^(n-k) - 1] / [(1+r)^n - 1] + # where B=balance, P=principal, r=rate, n=total payments, k=payments made + payments_before = (year_num - 1) * 12 + if payments_before > 0: + num_payments = loan_term_years * 12 + remaining_factor = (Decimal(1) + monthly_rate) ** Decimal( + num_payments - payments_before + ) + payment_factor = (Decimal(1) + monthly_rate) ** Decimal(num_payments) + balance_start = loan_amt * ( + (remaining_factor - Decimal(1)) / (payment_factor - Decimal(1)) + ) + else: + balance_start = loan_amt + + # Calculate interest for each month of the year + total_interest = Decimal("0") + balance = balance_start + for _ in range(12): + interest_payment = balance * monthly_rate + principal_payment = monthly_payment - interest_payment + total_interest += interest_payment + balance -= principal_payment + if balance <= 0: + break + + # Add depreciation + building_value = to_decimal(property_value) * Decimal("0.80") + annual_depreciation = building_value / Decimal("27.5") + + # Total deductions + total_deductions = total_interest + annual_depreciation + + # Tax savings + tax_savings = total_deductions * (to_decimal(tax_bracket) / Decimal(100)) + + return tax_savings.quantize(Decimal("0.01")) + + +# ── Depreciation & Tax Modeling ──────────────────────────────────────────────── + + +def annual_depreciation(purchase_price: Decimal, land_value: Decimal) -> Decimal: + """Calculate the annual straight-line depreciation for a residential rental property. + + The IRS allows 27.5-year straight-line depreciation on the building portion + (purchase price minus land value) of residential rental property. + + Args: + purchase_price: Total purchase price of the property (must be > 0). + land_value: Estimated value of the land component (must be >= 0 and + < purchase_price). Land is not depreciable. + + Returns: + Annual depreciation deduction as a Decimal representing the fixed deduction + for a full year. Year-by-year schedule handling is the caller's responsibility: + apply this amount for years 1-27 (full deduction), half this amount for year 28 + (remaining half-year fraction), and no deduction for years beyond year 28. + + Raises: + ValueError: If purchase_price <= 0. + ValueError: If land_value < 0. + ValueError: If land_value >= purchase_price (no depreciable basis). + + Example: + >>> annual_depreciation(Decimal("300000"), Decimal("50000")) + Decimal("9090.909090909090909090909091") + """ + pp = to_decimal(purchase_price) + lv = to_decimal(land_value) + + if pp <= Decimal("0"): + raise ValueError("purchase_price must be greater than zero") + if lv < Decimal("0"): + raise ValueError("land_value must be zero or greater") + if lv >= pp: + raise ValueError( + "land_value must be less than purchase_price; land is not depreciable" + ) + + depreciable_basis = pp - lv + return depreciable_basis / Decimal("27.5") + + +def after_tax_cash_flow( + noi: Decimal, + annual_debt_service: Decimal, + depreciation_deduction: Decimal, + marginal_tax_rate: Decimal, +) -> Decimal: + """Calculate after-tax cash flow including the depreciation tax shield. + + Formula: (NOI - debt_service) + (depreciation x tax_rate) + + The depreciation tax shield represents the tax savings from the paper loss of + depreciation, which reduces taxable income without a cash outflow. + + Args: + noi: Net Operating Income (annual). + annual_debt_service: Total annual mortgage payments (principal + interest). + depreciation_deduction: Annual depreciation deduction (e.g., from + ``annual_depreciation()``). + marginal_tax_rate: Investor's marginal income tax rate as a decimal in [0, 1] + (e.g., 0.24 for 24%). + + Returns: + After-tax cash flow as a Decimal. A positive value indicates net cash benefit. + + Raises: + ValueError: If marginal_tax_rate is outside the range [0, 1]. + + Example: + >>> after_tax_cash_flow( + ... Decimal("24000"), Decimal("18000"), Decimal("9091"), Decimal("0.24") + ... ) + Decimal("8181.84") + """ + rate = to_decimal(marginal_tax_rate) + if rate < Decimal("0") or rate > Decimal("1"): + raise ValueError( + "marginal_tax_rate must be between 0 and 1 inclusive " + f"(received {marginal_tax_rate})" + ) + + pre_tax_cf = to_decimal(noi) - to_decimal(annual_debt_service) + tax_shield = to_decimal(depreciation_deduction) * rate + return pre_tax_cf + tax_shield + + +def after_tax_irr( + cash_flows: Sequence[Decimal], + depreciation_schedule: Sequence[Decimal], + marginal_tax_rate: Decimal, +) -> Decimal: + """Calculate after-tax IRR by adjusting each period's cash flow by the depreciation tax shield. + + Each period's cash flow is increased by ``depreciation * marginal_tax_rate``. + The first cash flow (index 0) is assumed to be the initial investment (negative) + and is not adjusted -- depreciation tax shields begin in period 1. + + Args: + cash_flows: List of periodic cash flows. Index 0 is typically the initial + investment (negative). Must have at least 2 elements. + depreciation_schedule: List of annual depreciation amounts aligned to + cash_flows[1:]. If shorter than cash_flows[1:], missing periods are + treated as zero depreciation. + marginal_tax_rate: Investor's marginal income tax rate as a decimal in [0, 1]. + + Returns: + After-tax IRR as a Decimal. Returns Decimal("0") if numpy-financial cannot + converge (e.g., all non-negative flows or no sign change). + + Raises: + ValueError: If fewer than 2 cash flows are supplied. + ValueError: If marginal_tax_rate is outside the range [0, 1]. + + Example: + >>> after_tax_irr( + ... [Decimal("-100000"), Decimal("6000"), Decimal("106000")], + ... [Decimal("9091"), Decimal("9091")], + ... Decimal("0.24"), + ... ) + Decimal("0.0718") + """ + if len(cash_flows) < 2: + raise ValueError("At least 2 cash flows are required to calculate IRR") + + rate = to_decimal(marginal_tax_rate) + if rate < Decimal("0") or rate > Decimal("1"): + raise ValueError( + "marginal_tax_rate must be between 0 and 1 inclusive " + f"(received {marginal_tax_rate})" + ) + + # Build adjusted cash flows: index 0 (initial investment) is not adjusted. + adjusted: list[float] = [float(cash_flows[0])] + for i, cf in enumerate(cash_flows[1:]): + dep = ( + depreciation_schedule[i] if i < len(depreciation_schedule) else Decimal("0") + ) + shield = to_decimal(dep) * rate + adjusted.append(float(to_decimal(cf) + shield)) + + cf_array = np.array(adjusted, dtype=float) + try: + value = float(npf.irr(cf_array)) + if np.isnan(value) or np.isinf(value): + logger.warning( + "after_tax_irr: numpy_financial.irr returned non-finite value; returning 0" + ) + return Decimal("0") + return to_decimal(value) + except Exception as exc: + logger.warning("after_tax_irr: numpy_financial.irr raised %s; returning 0", exc) + return Decimal("0") + + +# ── Hold Period & Exit Analysis ──────────────────────────────────────────────── + + +def project_annual_cash_flows( + gross_rent_year1: Decimal, + operating_expense_year1: Decimal, + annual_debt_service: Decimal, + rent_growth_rate: Decimal, + expense_growth_rate: Decimal, + hold_years: int, +) -> list[Decimal]: + """Project year-by-year after-debt-service cash flows over a hold period. + + Each year's gross rent and operating expenses grow independently at their + respective compound annual growth rates. Annual debt service is assumed + constant (fixed-rate mortgage). + + Args: + gross_rent_year1: Gross rental income in year 1 (must be >= 0). + operating_expense_year1: Operating expenses in year 1 (must be >= 0). + annual_debt_service: Fixed annual mortgage payment (principal + interest; + must be >= 0). + rent_growth_rate: Annual rent growth rate as a decimal (e.g., 0.03 for 3%). + Must be in the range [-0.5, 0.5]. + expense_growth_rate: Annual expense growth rate as a decimal. + Must be in the range [-0.5, 0.5]. + hold_years: Number of years in the hold period. Must be in [1, 50]. + + Returns: + List of annual cash-flow Decimals, one entry per year (length == hold_years). + + Raises: + ValueError: If gross_rent_year1 or operating_expense_year1 or + annual_debt_service is negative. + ValueError: If hold_years is outside [1, 50]. + ValueError: If rent_growth_rate or expense_growth_rate is outside + [-0.5, 0.5]. + + Example: + >>> flows = project_annual_cash_flows( + ... Decimal("36000"), Decimal("12000"), Decimal("18000"), + ... Decimal("0.03"), Decimal("0.02"), 5, + ... ) + >>> len(flows) + 5 + """ + if hold_years < 1 or hold_years > 50: + raise ValueError(f"hold_years must be between 1 and 50 (received {hold_years})") + + r_rate = to_decimal(rent_growth_rate) + e_rate = to_decimal(expense_growth_rate) + rate_limit = Decimal("0.5") + if r_rate < -rate_limit or r_rate > rate_limit: + raise ValueError( + f"rent_growth_rate must be in [-0.5, 0.5] (received {rent_growth_rate})" + ) + if e_rate < -rate_limit or e_rate > rate_limit: + raise ValueError( + f"expense_growth_rate must be in [-0.5, 0.5] (received {expense_growth_rate})" + ) + + rent = to_decimal(gross_rent_year1) + expense = to_decimal(operating_expense_year1) + debt = to_decimal(annual_debt_service) + + if rent < Decimal("0"): + raise ValueError( + f"gross_rent_year1 must be zero or greater (received {gross_rent_year1})" + ) + if expense < Decimal("0"): + raise ValueError( + f"operating_expense_year1 must be zero or greater (received {operating_expense_year1})" + ) + if debt < Decimal("0"): + raise ValueError( + f"annual_debt_service must be zero or greater (received {annual_debt_service})" + ) + + cash_flows: list[Decimal] = [] + one = Decimal("1") + for year in range(1, hold_years + 1): + exponent = year - 1 + gross = rent * (one + r_rate) ** exponent + opex = expense * (one + e_rate) ** exponent + annual_noi = gross - opex + cash_flows.append(annual_noi - debt) + + return cash_flows + + +def project_property_value( + purchase_price: Decimal, + appreciation_rate: Decimal, + hold_years: int, +) -> Decimal: + """Project the market value of a property at the end of a hold period. + + Uses compound annual growth: + value = purchase_price * (1 + appreciation_rate)^hold_years + + Supports conservative / base / optimistic scenarios by varying + ``appreciation_rate`` (e.g., 0%, 3%, 5% for US residential). + + Args: + purchase_price: Original purchase price of the property (must be > 0). + appreciation_rate: Expected annual appreciation rate as a decimal. + Must be >= -1 (a rate of -1 implies a total loss of value; rates + below -1 are mathematically undefined for this formula). + hold_years: Number of years to project forward (must be in [1, 50]). + + Returns: + Projected property value as a Decimal. + + Raises: + ValueError: If purchase_price <= 0. + ValueError: If appreciation_rate < -1. + ValueError: If hold_years is outside [1, 50]. + + Example: + >>> project_property_value(Decimal("300000"), Decimal("0.03"), 10) + Decimal("403175....") + """ + pp = to_decimal(purchase_price) + rate = to_decimal(appreciation_rate) + + if pp <= Decimal("0"): + raise ValueError( + f"purchase_price must be greater than zero (received {purchase_price})" + ) + if rate < Decimal("-1"): + raise ValueError( + f"appreciation_rate must be >= -1 (received {appreciation_rate})" + ) + if hold_years < 1 or hold_years > 50: + raise ValueError(f"hold_years must be between 1 and 50 (received {hold_years})") + + return pp * (Decimal("1") + rate) ** hold_years + + +def net_sale_proceeds( + sale_price: Decimal, + original_purchase_price: Decimal, + outstanding_loan_balance: Decimal, + accumulated_depreciation: Decimal, + agent_commission_rate: Decimal = Decimal("0.06"), + closing_cost_rate: Decimal = Decimal("0.01"), + long_term_cg_rate: Decimal = Decimal("0.15"), + depreciation_recapture_rate: Decimal = Decimal("0.25"), +) -> Decimal: + """Calculate net cash to investor after costs and taxes upon property sale. + + Deductions applied in order: + 1. Agent commissions: sale_price * agent_commission_rate + 2. Closing costs: sale_price * closing_cost_rate + 3. Loan payoff: outstanding_loan_balance + 4. Capital gains tax: max(sale_price - original_purchase_price, 0) * long_term_cg_rate + 5. Depreciation recapture: accumulated_depreciation * depreciation_recapture_rate + + Args: + sale_price: Gross sale price of the property. + original_purchase_price: Price paid for the property at acquisition. + outstanding_loan_balance: Remaining mortgage balance at time of sale + (must be >= 0). + accumulated_depreciation: Total depreciation taken over the holding period + (must be >= 0). + agent_commission_rate: Broker commission as a decimal (default 0.06 = 6%). + closing_cost_rate: Seller's closing costs as a decimal (default 0.01 = 1%). + long_term_cg_rate: Federal long-term capital gains tax rate as a decimal + (default 0.15 = 15%). + depreciation_recapture_rate: IRS Section 1250 recapture rate as a decimal + (default 0.25 = 25%). + + Returns: + Net cash proceeds to investor as a Decimal. + + Raises: + ValueError: If outstanding_loan_balance < 0. + ValueError: If accumulated_depreciation < 0. + ValueError: If any rate parameter is outside [0, 1]. + + Example: + >>> net_sale_proceeds( + ... Decimal("400000"), Decimal("300000"), Decimal("200000"), + ... Decimal("45000"), + ... ) + Decimal("...") + """ + sp = to_decimal(sale_price) + opp = to_decimal(original_purchase_price) + loan_bal = to_decimal(outstanding_loan_balance) + acc_dep = to_decimal(accumulated_depreciation) + commission_rate = to_decimal(agent_commission_rate) + cc_rate = to_decimal(closing_cost_rate) + cg_rate = to_decimal(long_term_cg_rate) + recapture_rate = to_decimal(depreciation_recapture_rate) + + if loan_bal < Decimal("0"): + raise ValueError( + f"outstanding_loan_balance must be zero or greater (received {outstanding_loan_balance})" + ) + if acc_dep < Decimal("0"): + raise ValueError( + f"accumulated_depreciation must be zero or greater (received {accumulated_depreciation})" + ) + for name, val in [ + ("agent_commission_rate", commission_rate), + ("closing_cost_rate", cc_rate), + ("long_term_cg_rate", cg_rate), + ("depreciation_recapture_rate", recapture_rate), + ]: + if val < Decimal("0") or val > Decimal("1"): + raise ValueError( + f"{name} must be between 0 and 1 inclusive (received {val})" + ) + + gross_proceeds = sp - sp * commission_rate - sp * cc_rate - loan_bal + + capital_gain = sp - opp + cg_tax = max(capital_gain, Decimal("0")) * cg_rate + + recapture_tax = acc_dep * recapture_rate + + return gross_proceeds - cg_tax - recapture_tax + + +def total_return_summary( + purchase_price: Decimal, + down_payment: Decimal, + annual_cash_flows: list[Decimal], + net_sale_proceeds_amount: Decimal, +) -> Dict[str, Decimal]: + """Summarise total investment return over the hold period. + + Combines cumulative cash flows and net sale proceeds to compute total return + metrics. + + Args: + purchase_price: Original acquisition price of the property. + down_payment: Equity invested at purchase (positive value; used as the + year-0 outflow). + annual_cash_flows: List of annual after-debt-service cash flows from + ``project_annual_cash_flows()``. Must have at least 1 element. + net_sale_proceeds_amount: Net cash to investor upon sale from + ``net_sale_proceeds()``. + + Returns: + Dictionary with keys: purchase_price, total_cash_flow, net_sale_proceeds, + total_return, total_return_on_equity, annualized_irr. + + Raises: + ValueError: If annual_cash_flows is empty. + ValueError: If down_payment < 0. + + Example: + >>> summary = total_return_summary( + ... Decimal("300000"), Decimal("60000"), + ... [Decimal("6000")] * 10, Decimal("120000"), + ... ) + >>> summary["total_cash_flow"] + Decimal("60000") + """ + from investor_app.finance.utils import irr + + if not annual_cash_flows: + raise ValueError("annual_cash_flows must contain at least one element") + + dp = to_decimal(down_payment) + if dp < Decimal("0"): + raise ValueError( + f"down_payment must be zero or greater (received {down_payment})" + ) + + total_cf = sum(annual_cash_flows, Decimal("0")) + nsp = to_decimal(net_sale_proceeds_amount) + total_ret = total_cf + nsp + + if dp == Decimal("0"): + roe = Decimal("0") + else: + roe = total_ret / dp + + # Build IRR cash-flow series: year-0 outflow, annual CFs, exit-year bump + irr_flows: list[Decimal] = [-dp] + for i, cf in enumerate(annual_cash_flows): + if i == len(annual_cash_flows) - 1: + irr_flows.append(cf + nsp) + else: + irr_flows.append(cf) + + annualized = irr(irr_flows) + + return { + "purchase_price": to_decimal(purchase_price), + "total_cash_flow": total_cf, + "net_sale_proceeds": nsp, + "total_return": total_ret, + "total_return_on_equity": roe, + "annualized_irr": annualized, + } + + +def depreciation_recapture_tax( + accumulated_depreciation: Decimal, + recapture_rate: Decimal = Decimal("0.25"), +) -> Decimal: + """Calculate the depreciation recapture tax owed upon sale of the property. + + Under IRS Section 1250, accumulated depreciation is recaptured at a maximum + rate of 25% when the property is sold. + + Args: + accumulated_depreciation: Total depreciation taken over the holding period + (sum of annual deductions). Must be >= 0. + recapture_rate: IRS Section 1250 recapture rate as a decimal in [0, 1]. + Defaults to 0.25 (25%). + + Returns: + Depreciation recapture tax owed as a Decimal. + + Raises: + ValueError: If accumulated_depreciation < 0. + ValueError: If recapture_rate is outside [0, 1]. + + Example: + >>> depreciation_recapture_tax(Decimal("45000")) + Decimal("11250.00") + """ + acc_dep = to_decimal(accumulated_depreciation) + rate = to_decimal(recapture_rate) + + if acc_dep < Decimal("0"): + raise ValueError("accumulated_depreciation must be zero or greater") + if rate < Decimal("0") or rate > Decimal("1"): + raise ValueError( + "recapture_rate must be between 0 and 1 inclusive " + f"(received {recapture_rate})" + ) + + return acc_dep * rate + + +def calculate_annual_depreciation( + purchase_price: Decimal, + land_value_pct: Decimal = Decimal("0.20"), +) -> Decimal: + """Calculate annual straight-line depreciation for residential real estate. + + Uses the 27.5-year straight-line schedule on the improvement value + (purchase price minus land). + + Args: + purchase_price: Total purchase price of the property (must be > 0). + land_value_pct: Fraction of purchase price attributable to land, + expressed as a decimal (e.g. 0.20 for 20%). + Must be in the range [0, 1). Default 0.20. + + Returns: + Annual depreciation deduction as a Decimal. + + Raises: + ValueError: If purchase_price <= 0. + ValueError: If land_value_pct is outside [0, 1). + + Example: + >>> calculate_annual_depreciation(Decimal("200000"), Decimal("0.20")) + Decimal("5818.181818181818181818181818") + """ + pp = to_decimal(purchase_price) + lvp = to_decimal(land_value_pct) + + if pp <= Decimal("0"): + raise ValueError( + f"purchase_price must be greater than zero (received {purchase_price})" + ) + if lvp < Decimal("0") or lvp >= Decimal("1"): + raise ValueError( + f"land_value_pct must be in [0, 1) (received {land_value_pct})" + ) + + improvement_value = pp * (Decimal("1") - lvp) + return improvement_value / Decimal("27.5") + + +def calculate_after_tax_cashflow( + pre_tax_annual_cashflow: Decimal, + annual_depreciation: Decimal, + marginal_tax_rate: Decimal, +) -> Decimal: + """Calculate after-tax cash flow including the depreciation tax shield. + + As simplified model — does not account for passive activity loss (PAL) rules, + cost segregation, or other advanced tax strategies. + A UI disclaimer should note this limitation. + + Formula: + taxable_income = pre_tax_annual_cashflow - annual_depreciation + if taxable_income < 0: + tax_savings = abs(taxable_income) * marginal_tax_rate + after_tax = pre_tax_annual_cashflow + tax_savings + else: + tax_owed = taxable_income * marginal_tax_rate + after_tax = pre_tax_annual_cashflow - tax_owed + + Args: + pre_tax_annual_cashflow: Annual pre-tax cash flow from the property. + Can be negative. + annual_depreciation: Annual depreciation deduction (built-in from + ``calculate_annual_depreciation``). + marginal_tax_rate: Investor's marginal income-tax rate as a decimal + in [0, 1] (e.g., 0.32 for 32%). + + Returns: + After-tax annual cash flow as a Decimal. + + Raises: + ValueError: If marginal_tax_rate is outside [0, 1]. + + Example: + >>> calculate_after_tax_cashflow( + ... Decimal("6000"), Decimal("5818"), Decimal("0.32") + ... ) + Decimal("5941.76") + """ + cashflow = to_decimal(pre_tax_annual_cashflow) + depreciation = to_decimal(annual_depreciation) + rate = to_decimal(marginal_tax_rate) + + if rate < Decimal("0") or rate > Decimal("1"): + raise ValueError( + f"marginal_tax_rate must be in [0, 1] (received {marginal_tax_rate})" + ) + + taxable_income = cashflow - depreciation + + if taxable_income < 0: + tax_savings = abs(taxable_income) * rate + return cashflow + tax_savings + tax_owed = taxable_income * rate + return cashflow - tax_owed diff --git a/investor_app/finance/utils.py b/investor_app/finance/utils.py index e12acfe5..93b0374b 100644 --- a/investor_app/finance/utils.py +++ b/investor_app/finance/utils.py @@ -1,15 +1,23 @@ -from datetime import datetime +"""Core finance math for investment KPIs. + +This module intentionally holds only the low-level primitives and the +Django-coupled analysis function. Specialized math lives in sibling modules: + +- ``mortgage`` — monthly mortgage, carrying costs, break-even rent, paydown, appreciation, ROI components +- ``taxes`` — depreciation, after-tax cash flow / IRR, hold-period projections, sale proceeds +- ``scoring`` — 1% rule, GRM, price-to-rent and market normalization helpers +- ``strategies`` — flip, buy-and-hold, vacation rental, BRRRR +""" + +from __future__ import annotations + from decimal import Decimal import logging -from statistics import median -from typing import Any, Dict, Sequence, TypedDict import numpy as np import numpy_financial as npf -# removed unused 'settings' import - -from core.models import InvestmentAnalysis, Listing, Property +from core.models import InvestmentAnalysis, Property logger = logging.getLogger(__name__) @@ -23,12 +31,6 @@ def noi(monthly_income: Decimal, monthly_expenses: Decimal) -> Decimal: NOI = (Monthly Income - Monthly Expenses) x 12 - Derivation: NOI is the income-statement identity for property-level - operating income - gross income less operating expenses, before any - financing costs (debt service) or capital expenditures. Annualizing - the monthly figure gives the standard basis for cap rate, DSCR, and - other per-year KPIs. - Args: monthly_income: Gross monthly income (rent + other income). monthly_expenses: Monthly operating expenses (excludes debt service). @@ -46,12 +48,6 @@ def cap_rate(annual_noi: Decimal, purchase_price: Decimal) -> Decimal: Cap Rate = Annual NOI / Purchase Price - Derivation: Cap rate is the property's unlevered yield - the return - an all-cash buyer would earn on the purchase price from operations - alone, independent of financing. It is the direct-capitalization - analogue of a bond's coupon yield: NOI divided by price, both taken - as of the same period. - Args: annual_noi: Annual Net Operating Income. purchase_price: Total purchase price of the property. @@ -70,12 +66,6 @@ def cash_on_cash(annual_cash_flow: Decimal, total_cash_invested: Decimal) -> Dec CoC = Annual Cash Flow / Total Cash Invested - Derivation: Unlike cap rate, CoC is a levered return - it measures - the actual cash yield on the investor's own capital (down payment, - closing costs, rehab) after debt service, since annual_cash_flow is - NOI net of mortgage payments. It answers what the investor earns on - the cash actually put in, not what the property earns overall. - Args: annual_cash_flow: Annual cash flow after debt service. total_cash_invested: Total cash invested by the investor (down @@ -95,12 +85,6 @@ def dscr(annual_noi: Decimal, annual_debt_service: Decimal) -> Decimal: DSCR = Annual NOI / Annual Debt Service - Derivation: DSCR is a lender solvency ratio, not an investor return - metric - it measures how many times over the property's NOI covers - its annual mortgage payments (principal + interest). A DSCR below - 1.0 means NOI alone cannot cover debt service; most lenders require - DSCR >= 1.20-1.25 as an underwriting minimum. - Args: annual_noi: Annual Net Operating Income. annual_debt_service: Total annual mortgage payments (P&I). @@ -117,16 +101,7 @@ def dscr(annual_noi: Decimal, annual_debt_service: Decimal) -> Decimal: def irr(cashflows: list[Decimal]) -> Decimal: """Calculate the Internal Rate of Return (IRR) for a cashflow series. - IRR is the discount rate r solving NPV(r) = 0, where - NPV(r) = sum(cashflows[t] / (1 + r)^t) over each period t. - - Derivation: IRR has no closed-form solution in general - it is - defined implicitly as the root of the NPV equation, found here via - numpy_financial's iterative solver. It answers what constant annual - return would make this series of cash in/outflows break even, which - lets cashflows of uneven timing/magnitude (purchase, several years - of rental income, then a sale) be compared on a single annualized - basis, unlike cap rate or CoC which are single-period snapshots. + IRR is the discount rate r solving NPV(r) = 0. Args: cashflows: Cashflow series; cashflows[0] is the initial outflow @@ -135,8 +110,7 @@ def irr(cashflows: list[Decimal]) -> Decimal: Returns: IRR as a Decimal (e.g. 0.15 for 15%). Returns Decimal("0") if - the solver fails to converge or returns a non-finite value (no - real root - e.g. all-same-sign cashflows). + the solver fails to converge or returns a non-finite value. """ cf = np.array([float(c) for c in cashflows], dtype=float) try: @@ -175,564 +149,8 @@ def build_cashflows( ) -def score_listing_v1(listing: Listing) -> Decimal: - """Basic Phase 1 scoring using price per sq ft and freshness. - - Higher score is better. This is a simple heuristic for MVP. - - .. deprecated:: - Use :func:`core.services.scoring.score_listing_v2` for - underwriting-grade scores. Will be removed after all callers - are migrated. - """ - import warnings as _warnings - - _warnings.warn( - "score_listing_v1 is deprecated; use core.services.scoring.score_listing_v2 " - "for investor-grade underwriting scores.", - DeprecationWarning, - stacklevel=2, - ) - price = to_decimal(listing.price) if listing.price is not None else Decimal("0") - sq_ft = Decimal(listing.sq_ft or 0) - # price per square foot (lower is better) - ppsf = (price / sq_ft) if sq_ft > 0 else Decimal("0") - # freshness boost: recent postings get a bump - from django.utils import timezone - - now = timezone.now() - age_hours = Decimal(max(1, (now - listing.posted_at).total_seconds() / 3600)) - freshness = Decimal(1) / age_hours - - # Combine with weights - # To avoid division by zero or extreme values, clamp ppsf - ppsf_clamped = ppsf if ppsf > 0 else Decimal("1000000") - score = (Decimal(1000000) / ppsf_clamped) + (freshness * Decimal(10)) - return score - - -def calculate_monthly_mortgage( - loan_amount: Decimal, interest_rate: Decimal, loan_term_years: int -) -> Decimal: - """Calculate monthly mortgage payment (principal and interest). - - Args: - loan_amount: Total loan amount - interest_rate: Annual interest rate as percentage (e.g., 7.5 for 7.5%) - loan_term_years: Loan term in years - - Returns: - Monthly payment amount - """ - loan_amt = to_decimal(loan_amount) - rate = to_decimal(interest_rate) - - if loan_amt == 0: - return Decimal("0") - - if rate == 0: - # No interest - simple division - return loan_amt / Decimal(loan_term_years * 12) - - monthly_rate = rate / Decimal(100) / Decimal(12) - num_payments = Decimal(loan_term_years * 12) - - # Standard amortization formula: M = P[r(1+r)^n]/[(1+r)^n-1] - factor = (Decimal(1) + monthly_rate) ** num_payments - monthly_payment = loan_amt * (monthly_rate * factor) / (factor - Decimal(1)) - - return monthly_payment.quantize(Decimal("0.01")) - - -def calculate_property_tax( - property_value: Decimal, tax_rate_percent: Decimal -) -> Decimal: - """Calculate annual property tax. - - Args: - property_value: Property value/assessed value - tax_rate_percent: Property tax rate as percentage (e.g., 2.1 for 2.1%) - - Returns: - Annual property tax amount - """ - return ( - to_decimal(property_value) * to_decimal(tax_rate_percent) / Decimal(100) - ).quantize(Decimal("0.01")) - - -def estimate_insurance( - property_value: Decimal, - property_type: str = "single-family", - year_built: int = 2000, -) -> Decimal: - """Estimate annual insurance cost. - - Args: - property_value: Property value - property_type: Type of property (single-family, condo, multi-family) - year_built: Year property was built - - Returns: - Estimated annual insurance premium - """ - base_rate = Decimal("1200") # National average for $250k home - - # Adjust for property value - value_factor = to_decimal(property_value) / Decimal("250000") - - # Adjust for property type - type_factors = { - "single-family": Decimal("1.0"), - "condo": Decimal("0.7"), - "multi-family": Decimal("1.3"), - "commercial": Decimal("1.5"), - } - type_factor = type_factors.get(property_type, Decimal("1.0")) - - # Adjust for age - current_year = datetime.now().year - age = max(0, current_year - year_built) - age_factor = Decimal("1.0") + (Decimal(age) / Decimal(50)) - - annual_insurance = base_rate * value_factor * type_factor * age_factor - return annual_insurance.quantize(Decimal("0.01")) - - -def calculate_maintenance_reserve( - property_value: Decimal, - year_built: int = 2000, - annual_percent: Decimal = Decimal("1.0"), -) -> Decimal: - """Calculate annual maintenance reserve (1% rule with age adjustment). - - Args: - property_value: Property value - year_built: Year property was built - annual_percent: Base annual percentage of property value (default 1%) - - Returns: - Annual maintenance reserve amount - """ - base_maintenance = ( - to_decimal(property_value) * to_decimal(annual_percent) / Decimal(100) - ) - - # Adjust for age - if year_built < 1980: - age_factor = Decimal("1.5") - elif year_built < 2000: - age_factor = Decimal("1.2") - else: - age_factor = Decimal("1.0") - - return (base_maintenance * age_factor).quantize(Decimal("0.01")) - - -def calculate_break_even_rent( - monthly_carrying_costs: Decimal, - vacancy_rate_percent: Decimal, - property_management_percent: Decimal = Decimal("10"), -) -> Dict[str, Decimal]: - """Calculate break-even rent needed to cover carrying costs. - - Args: - monthly_carrying_costs: Total monthly carrying costs (excluding property management) - vacancy_rate_percent: Vacancy rate as percentage (e.g., 8 for 8%) - property_management_percent: Property management fee as percentage of rent - - Returns: - Dictionary with breakEvenRent and related metrics - """ - costs = to_decimal(monthly_carrying_costs) - vacancy = to_decimal(vacancy_rate_percent) / Decimal(100) - mgmt = to_decimal(property_management_percent) / Decimal(100) - - # Formula: rent * (1 - vacancy) * (1 - mgmt) = costs - # rent = costs / ((1 - vacancy) * (1 - mgmt)) - divisor = (Decimal(1) - vacancy) * (Decimal(1) - mgmt) - if divisor == 0: - return { - "monthly": Decimal("0"), - "annual": Decimal("0"), - } - break_even = costs / divisor - - return { - "monthly": break_even.quantize(Decimal("0.01")), - "annual": (break_even * Decimal(12)).quantize(Decimal("0.01")), - } - - -def calculate_carrying_costs( - purchase_price: Decimal, - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - property_tax_rate: Decimal, - insurance_annual: Decimal | None = None, - hoa_monthly: Decimal = Decimal("0"), - utilities_monthly: Decimal = Decimal("0"), - maintenance_annual_percent: Decimal = Decimal("1.0"), - property_type: str = "single-family", - year_built: int = 2000, -) -> Dict[str, Any]: - """Calculate complete carrying costs breakdown. - - Args: - purchase_price: Property purchase price - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - property_tax_rate: Property tax rate as percentage - insurance_annual: Annual insurance cost (if None, will estimate) - hoa_monthly: Monthly HOA fees - utilities_monthly: Monthly utility costs - maintenance_annual_percent: Maintenance as percentage of property value - property_type: Type of property - year_built: Year property was built - - Returns: - Dictionary with detailed carrying cost breakdown - """ - # Calculate mortgage - monthly_mortgage = calculate_monthly_mortgage( - loan_amount, interest_rate, loan_term_years - ) - - # Calculate property tax - annual_property_tax = calculate_property_tax(purchase_price, property_tax_rate) - monthly_property_tax = annual_property_tax / Decimal(12) - - # Calculate or use provided insurance - if insurance_annual is None: - annual_insurance = estimate_insurance(purchase_price, property_type, year_built) - else: - annual_insurance = to_decimal(insurance_annual) - monthly_insurance = annual_insurance / Decimal(12) - - # Calculate maintenance - annual_maintenance = calculate_maintenance_reserve( - purchase_price, year_built, maintenance_annual_percent - ) - monthly_maintenance = annual_maintenance / Decimal(12) - - # Monthly costs - monthly_hoa = to_decimal(hoa_monthly) - monthly_utilities = to_decimal(utilities_monthly) - - # Calculate totals - monthly_total = ( - monthly_mortgage - + monthly_property_tax - + monthly_insurance - + monthly_hoa - + monthly_utilities - + monthly_maintenance - ) - - annual_total = monthly_total * Decimal(12) - - return { - "monthly": { - "mortgage": monthly_mortgage.quantize(Decimal("0.01")), - "propertyTax": monthly_property_tax.quantize(Decimal("0.01")), - "insurance": monthly_insurance.quantize(Decimal("0.01")), - "hoa": monthly_hoa.quantize(Decimal("0.01")), - "utilities": monthly_utilities.quantize(Decimal("0.01")), - "maintenance": monthly_maintenance.quantize(Decimal("0.01")), - "total": monthly_total.quantize(Decimal("0.01")), - }, - "annual": { - "mortgage": (monthly_mortgage * Decimal(12)).quantize(Decimal("0.01")), - "propertyTax": annual_property_tax.quantize(Decimal("0.01")), - "insurance": annual_insurance.quantize(Decimal("0.01")), - "hoa": (monthly_hoa * Decimal(12)).quantize(Decimal("0.01")), - "utilities": (monthly_utilities * Decimal(12)).quantize(Decimal("0.01")), - "maintenance": annual_maintenance.quantize(Decimal("0.01")), - "total": annual_total.quantize(Decimal("0.01")), - }, - } - - -def calculate_principal_paydown( - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - num_years: int = 1, -) -> Decimal: - """Calculate total principal paid down over specified number of years. - - Args: - loan_amount: Initial loan amount - interest_rate: Annual interest rate as percentage (e.g., 7.5 for 7.5%) - loan_term_years: Total loan term in years - num_years: Number of years to calculate paydown for (default 1) - - Returns: - Total principal paid down over the specified period - """ - if loan_amount == 0 or num_years == 0: - return Decimal("0") - - loan_amt = to_decimal(loan_amount) - rate = to_decimal(interest_rate) - - if rate == 0: - # No interest - equal principal payments - monthly_principal = loan_amt / Decimal(loan_term_years * 12) - return monthly_principal * Decimal(num_years * 12) - - monthly_rate = rate / Decimal(100) / Decimal(12) - monthly_payment = calculate_monthly_mortgage( - loan_amount, interest_rate, loan_term_years - ) - - # Calculate principal paid by simulating each payment - remaining_balance = loan_amt - total_principal_paid = Decimal("0") - - for month in range(num_years * 12): - interest_payment = remaining_balance * monthly_rate - principal_payment = monthly_payment - interest_payment - total_principal_paid += principal_payment - remaining_balance -= principal_payment - - if remaining_balance <= 0: - break - - return total_principal_paid.quantize(Decimal("0.01")) - - -def calculate_appreciation( - property_value: Decimal, - appreciation_rate: Decimal, - num_years: int = 1, -) -> Decimal: - """Calculate property appreciation over specified number of years. - - Args: - property_value: Current property value - appreciation_rate: Annual appreciation rate as percentage (e.g., 3.0 for 3%) - num_years: Number of years to project (default 1) - - Returns: - Total appreciation amount - """ - value = to_decimal(property_value) - rate = to_decimal(appreciation_rate) / Decimal(100) - - future_value = value * ((Decimal(1) + rate) ** Decimal(num_years)) - appreciation = future_value - value - - return appreciation.quantize(Decimal("0.01")) - - -def calculate_tax_benefits( - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - property_value: Decimal, - tax_bracket: Decimal = Decimal("24"), - year_num: int = 1, -) -> Decimal: - """Calculate tax benefits from mortgage interest deduction and depreciation. - - Args: - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - property_value: Property value (for depreciation calculation) - tax_bracket: Marginal tax bracket as percentage (default 24%) - year_num: Which year to calculate benefits for (default 1) - - Returns: - Total tax benefit amount for the specified year - """ - if loan_amount == 0: - # All cash - only depreciation benefit - # Residential property: 27.5 year straight-line depreciation on 80% of value - building_value = to_decimal(property_value) * Decimal("0.80") - annual_depreciation = building_value / Decimal("27.5") - tax_savings = annual_depreciation * (to_decimal(tax_bracket) / Decimal(100)) - return tax_savings.quantize(Decimal("0.01")) - - # Calculate interest paid in specific year - loan_amt = to_decimal(loan_amount) - rate = to_decimal(interest_rate) - monthly_rate = rate / Decimal(100) / Decimal(12) - monthly_payment = calculate_monthly_mortgage( - loan_amount, interest_rate, loan_term_years - ) - - # Calculate remaining balance at start of year - # Uses standard amortization formula: B = P * [(1+r)^(n-k) - 1] / [(1+r)^n - 1] - # where B=balance, P=principal, r=rate, n=total payments, k=payments made - payments_before = (year_num - 1) * 12 - if payments_before > 0: - num_payments = loan_term_years * 12 - remaining_factor = (Decimal(1) + monthly_rate) ** Decimal( - num_payments - payments_before - ) - payment_factor = (Decimal(1) + monthly_rate) ** Decimal(num_payments) - balance_start = loan_amt * ( - (remaining_factor - Decimal(1)) / (payment_factor - Decimal(1)) - ) - else: - balance_start = loan_amt - - # Calculate interest for each month of the year - total_interest = Decimal("0") - balance = balance_start - for _ in range(12): - interest_payment = balance * monthly_rate - principal_payment = monthly_payment - interest_payment - total_interest += interest_payment - balance -= principal_payment - if balance <= 0: - break - - # Add depreciation - building_value = to_decimal(property_value) * Decimal("0.80") - annual_depreciation = building_value / Decimal("27.5") - - # Total deductions - total_deductions = total_interest + annual_depreciation - - # Tax savings - tax_savings = total_deductions * (to_decimal(tax_bracket) / Decimal(100)) - - return tax_savings.quantize(Decimal("0.01")) - - -def calculate_roi_components( - purchase_price: Decimal, - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - total_cash_invested: Decimal, - annual_cash_flow: Decimal, - appreciation_rate: Decimal = Decimal("3.0"), - tax_bracket: Decimal = Decimal("24"), - num_years: int = 5, -) -> Dict[str, Any]: - """Calculate comprehensive ROI with all components over multiple years. - - Args: - purchase_price: Property purchase price - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - total_cash_invested: Total cash invested (down payment + closing costs) - annual_cash_flow: Annual pre-tax cash flow - appreciation_rate: Annual appreciation rate as percentage (default 3%) - tax_bracket: Marginal tax bracket as percentage (default 24%) - num_years: Number of years to project (default 5) - - Returns: - Dictionary with ROI components and projections - """ - # Year 1 calculations - year1_cash_flow = to_decimal(annual_cash_flow) - year1_principal_paydown = calculate_principal_paydown( - loan_amount, interest_rate, loan_term_years, 1 - ) - year1_appreciation = calculate_appreciation(purchase_price, appreciation_rate, 1) - year1_tax_benefits = calculate_tax_benefits( - loan_amount, interest_rate, loan_term_years, purchase_price, tax_bracket, 1 - ) - - year1_total_return = ( - year1_cash_flow - + year1_principal_paydown - + year1_appreciation - + year1_tax_benefits - ) - - if total_cash_invested > 0: - year1_roi = year1_total_return / to_decimal(total_cash_invested) * Decimal(100) - else: - year1_roi = Decimal("0") - - # Multi-year calculations - total_cash_flow = year1_cash_flow * Decimal( - num_years - ) # Simplified: assumes constant - total_principal_paydown = calculate_principal_paydown( - loan_amount, interest_rate, loan_term_years, num_years - ) - total_appreciation = calculate_appreciation( - purchase_price, appreciation_rate, num_years - ) - - # Sum tax benefits for each year - total_tax_benefits = Decimal("0") - for year in range(1, num_years + 1): - total_tax_benefits += calculate_tax_benefits( - loan_amount, - interest_rate, - loan_term_years, - purchase_price, - tax_bracket, - year, - ) - - total_return = ( - total_cash_flow - + total_principal_paydown - + total_appreciation - + total_tax_benefits - ) - - if total_cash_invested > 0: - multi_year_roi = total_return / to_decimal(total_cash_invested) * Decimal(100) - # Annualized return - annualized_roi = ( - (Decimal(1) + multi_year_roi / Decimal(100)) - ** (Decimal(1) / Decimal(num_years)) - - Decimal(1) - ) * Decimal(100) - else: - multi_year_roi = Decimal("0") - annualized_roi = Decimal("0") - - # Component percentages for year 1 - if year1_total_return > 0: - cash_flow_pct = year1_cash_flow / year1_total_return * Decimal(100) - appreciation_pct = year1_appreciation / year1_total_return * Decimal(100) - equity_pct = year1_principal_paydown / year1_total_return * Decimal(100) - tax_pct = year1_tax_benefits / year1_total_return * Decimal(100) - else: - cash_flow_pct = appreciation_pct = equity_pct = tax_pct = Decimal("0") - - return { - "year1": { - "roi": year1_roi.quantize(Decimal("0.1")), - "totalReturn": year1_total_return.quantize(Decimal("0.01")), - "cashFlow": year1_cash_flow.quantize(Decimal("0.01")), - "principalPaydown": year1_principal_paydown.quantize(Decimal("0.01")), - "appreciation": year1_appreciation.quantize(Decimal("0.01")), - "taxBenefits": year1_tax_benefits.quantize(Decimal("0.01")), - }, - f"year{num_years}Projected": { - "roi": multi_year_roi.quantize(Decimal("0.1")), - "annualizedRoi": annualized_roi.quantize(Decimal("0.1")), - "totalReturn": total_return.quantize(Decimal("0.01")), - "totalCashFlow": total_cash_flow.quantize(Decimal("0.01")), - "totalPrincipalPaydown": total_principal_paydown.quantize(Decimal("0.01")), - "totalAppreciation": total_appreciation.quantize(Decimal("0.01")), - "totalTaxBenefits": total_tax_benefits.quantize(Decimal("0.01")), - }, - "components": { - "cashFlowReturn": cash_flow_pct.quantize(Decimal("0.1")), - "appreciationReturn": appreciation_pct.quantize(Decimal("0.1")), - "equityBuildupReturn": equity_pct.quantize(Decimal("0.1")), - "taxBenefitsReturn": tax_pct.quantize(Decimal("0.1")), - }, - } - - def compute_analysis_for_property(prop: Property) -> InvestmentAnalysis: + """Compute and persist the full investment analysis for a property.""" incomes = prop.rental_incomes.all() expenses = prop.operating_expenses.all() @@ -777,309 +195,6 @@ def compute_analysis_for_property(prop: Property) -> InvestmentAnalysis: return analysis -def calculate_flip_strategy( - purchase_price: Decimal, - renovation_costs: Decimal, - holding_period_months: int, - expected_sale_price: Decimal, - selling_costs: Decimal, - down_payment: Decimal, - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - closing_costs: Decimal, - property_tax_rate: Decimal, - insurance_annual: Decimal | None = None, - utilities_monthly: Decimal = Decimal("0"), - property_type: str = "single-family", - year_built: int = 2000, -) -> Dict[str, Any]: - """Calculate fix-and-flip strategy returns. - - Args: - purchase_price: Property purchase price - renovation_costs: Total renovation costs - holding_period_months: How long to hold before selling (3-6 months typical) - expected_sale_price: Expected sale price after renovation - selling_costs: Total selling costs (realtor fees, etc.) - down_payment: Down payment amount - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - closing_costs: Closing costs on purchase - property_tax_rate: Property tax rate as percentage - insurance_annual: Annual insurance cost - utilities_monthly: Monthly utility costs while vacant - property_type: Type of property - year_built: Year property was built - - Returns: - Dictionary with flip strategy analysis - """ - # Calculate holding costs for the period - monthly_mortgage = calculate_monthly_mortgage( - loan_amount, interest_rate, loan_term_years - ) - annual_property_tax = calculate_property_tax(purchase_price, property_tax_rate) - monthly_property_tax = annual_property_tax / Decimal(12) - - if insurance_annual is None: - annual_insurance = estimate_insurance(purchase_price, property_type, year_built) - else: - annual_insurance = to_decimal(insurance_annual) - monthly_insurance = annual_insurance / Decimal(12) - - monthly_holding_costs = ( - monthly_mortgage - + monthly_property_tax - + monthly_insurance - + to_decimal(utilities_monthly) - ) - - total_holding_costs = monthly_holding_costs * Decimal(holding_period_months) - - # Total investment - total_investment = ( - to_decimal(down_payment) - + to_decimal(closing_costs) - + to_decimal(renovation_costs) - ) - - # Calculate proceeds - gross_sale_proceeds = to_decimal(expected_sale_price) - net_sale_proceeds = gross_sale_proceeds - to_decimal(selling_costs) - - # Remaining loan balance after holding period - principal_paid = calculate_principal_paydown( - loan_amount, interest_rate, loan_term_years, holding_period_months // 12 - ) - remaining_loan = to_decimal(loan_amount) - principal_paid - - # Net profit - net_profit = ( - net_sale_proceeds - remaining_loan - total_holding_costs - total_investment - ) - - # ROI - if total_investment > 0: - roi_percent = net_profit / total_investment * Decimal(100) - # Annualized return - years = Decimal(holding_period_months) / Decimal(12) - if years > 0 and roi_percent > Decimal("-100"): - annualized_return = ( - (Decimal(1) + roi_percent / Decimal(100)) ** (Decimal(1) / years) - - Decimal(1) - ) * Decimal(100) - else: - annualized_return = Decimal("0") - else: - roi_percent = Decimal("0") - annualized_return = Decimal("0") - - return { - "totalInvestment": total_investment.quantize(Decimal("0.01")), - "holdingCosts": total_holding_costs.quantize(Decimal("0.01")), - "renovationCosts": to_decimal(renovation_costs).quantize(Decimal("0.01")), - "saleProceeds": gross_sale_proceeds.quantize(Decimal("0.01")), - "sellingCosts": to_decimal(selling_costs).quantize(Decimal("0.01")), - "netProfit": net_profit.quantize(Decimal("0.01")), - "roi": roi_percent.quantize(Decimal("0.1")), - "timeframe": f"{holding_period_months} months", - "annualizedReturn": annualized_return.quantize(Decimal("0.1")), - } - - -def calculate_rental_strategy( - purchase_price: Decimal, - down_payment: Decimal, - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - closing_costs: Decimal, - annual_cash_flow: Decimal, - appreciation_rate: Decimal = Decimal("3.0"), - holding_period_years: int = 5, -) -> Dict[str, Any]: - """Calculate buy-and-hold rental strategy returns. - - Args: - purchase_price: Property purchase price - down_payment: Down payment amount - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - closing_costs: Closing costs - annual_cash_flow: Annual cash flow (can be negative) - appreciation_rate: Annual appreciation rate as percentage - holding_period_years: How many years to hold - - Returns: - Dictionary with rental strategy analysis - """ - total_investment = to_decimal(down_payment) + to_decimal(closing_costs) - - # Simplified: assume constant cash flow (in reality it would improve over time) - total_cash_flow = to_decimal(annual_cash_flow) * Decimal(holding_period_years) - - # Equity buildup from mortgage paydown - equity_buildup = calculate_principal_paydown( - loan_amount, interest_rate, loan_term_years, holding_period_years - ) - - # Appreciation - appreciation = calculate_appreciation( - purchase_price, appreciation_rate, holding_period_years - ) - - # Total gain - total_gain = total_cash_flow + equity_buildup + appreciation - - # ROI - if total_investment > 0: - roi_percent = total_gain / total_investment * Decimal(100) - annualized_return = ( - (Decimal(1) + roi_percent / Decimal(100)) - ** (Decimal(1) / Decimal(holding_period_years)) - - Decimal(1) - ) * Decimal(100) - else: - roi_percent = Decimal("0") - annualized_return = Decimal("0") - - return { - "totalInvestment": total_investment.quantize(Decimal("0.01")), - "year1CashFlow": to_decimal(annual_cash_flow).quantize(Decimal("0.01")), - f"year{holding_period_years}CashFlow": to_decimal(annual_cash_flow).quantize( - Decimal("0.01") - ), # Simplified - f"totalCashFlow{holding_period_years}Years": total_cash_flow.quantize( - Decimal("0.01") - ), - f"equityBuildup{holding_period_years}Years": equity_buildup.quantize( - Decimal("0.01") - ), - f"appreciation{holding_period_years}Years": appreciation.quantize( - Decimal("0.01") - ), - f"totalGain{holding_period_years}Years": total_gain.quantize(Decimal("0.01")), - "roi": roi_percent.quantize(Decimal("0.1")), - "timeframe": f"{holding_period_years} years", - "annualizedReturn": annualized_return.quantize(Decimal("0.1")), - } - - -def calculate_vacation_rental_strategy( - purchase_price: Decimal, - down_payment: Decimal, - loan_amount: Decimal, - interest_rate: Decimal, - loan_term_years: int, - closing_costs: Decimal, - avg_nightly_rate: Decimal, - avg_occupancy_rate: Decimal, # As percentage (e.g., 65 for 65%) - cleaning_fee_per_stay: Decimal, - monthly_operating_expenses: Decimal, - holding_period_years: int = 5, - avg_stay_length_nights: int = 3, # Typical vacation rental stay length -) -> Dict[str, Any]: - """Calculate vacation rental strategy returns. - - Args: - purchase_price: Property purchase price - down_payment: Down payment amount - loan_amount: Mortgage loan amount - interest_rate: Annual interest rate as percentage - loan_term_years: Loan term in years - closing_costs: Closing costs - avg_nightly_rate: Average nightly rental rate - avg_occupancy_rate: Average occupancy rate as percentage - cleaning_fee_per_stay: Cleaning fee per stay - monthly_operating_expenses: Monthly operating expenses - holding_period_years: How many years to hold - avg_stay_length_nights: Average length of stay in nights (default 3) - - Returns: - Dictionary with vacation rental strategy analysis - """ - total_investment = to_decimal(down_payment) + to_decimal(closing_costs) - - # Calculate annual income - nights_per_year = Decimal(365) - occupied_nights = nights_per_year * to_decimal(avg_occupancy_rate) / Decimal(100) - - # Calculate number of stays based on average stay length - avg_stay_length = Decimal(avg_stay_length_nights) - num_stays = occupied_nights / avg_stay_length - - annual_rental_income = occupied_nights * to_decimal( - avg_nightly_rate - ) + num_stays * to_decimal(cleaning_fee_per_stay) - - # Annual expenses - monthly_mortgage = calculate_monthly_mortgage( - loan_amount, interest_rate, loan_term_years - ) - annual_debt_service = monthly_mortgage * Decimal(12) - annual_operating_expenses = to_decimal(monthly_operating_expenses) * Decimal(12) - - # Cash flow - annual_cash_flow = ( - annual_rental_income - annual_debt_service - annual_operating_expenses - ) - - # Calculate year 1 CoC - if total_investment > 0: - coc_return = annual_cash_flow / total_investment * Decimal(100) - else: - coc_return = Decimal("0") - - # 5-year projection (simplified) - total_cash_flow = annual_cash_flow * Decimal(holding_period_years) - - # Equity buildup - equity_buildup = calculate_principal_paydown( - loan_amount, interest_rate, loan_term_years, holding_period_years - ) - - # Appreciation (3% default) - appreciation = calculate_appreciation( - purchase_price, Decimal("3.0"), holding_period_years - ) - - total_gain = total_cash_flow + equity_buildup + appreciation - - if total_investment > 0: - roi_percent = total_gain / total_investment * Decimal(100) - annualized_return = ( - (Decimal(1) + roi_percent / Decimal(100)) - ** (Decimal(1) / Decimal(holding_period_years)) - - Decimal(1) - ) * Decimal(100) - else: - roi_percent = Decimal("0") - annualized_return = Decimal("0") - - return { - "totalInvestment": total_investment.quantize(Decimal("0.01")), - "avgMonthlyIncome": (annual_rental_income / Decimal(12)).quantize( - Decimal("0.01") - ), - "avgMonthlyExpenses": ( - (annual_debt_service + annual_operating_expenses) / Decimal(12) - ).quantize(Decimal("0.01")), - "netCashFlowYear1": annual_cash_flow.quantize(Decimal("0.01")), - "cocReturn": coc_return.quantize(Decimal("0.1")), - "roi": roi_percent.quantize(Decimal("0.1")), - "timeframe": f"{holding_period_years} years", - "annualizedReturn": annualized_return.quantize(Decimal("0.1")), - "seasonalityImpact": ( - "High - Occupancy varies by season" - if avg_occupancy_rate < 75 - else "Moderate" - ), - } - - def calculate_whatif_monthly_cashflow( annual_noi: Decimal, taxes: Decimal = Decimal("0"), @@ -1115,1233 +230,3 @@ def calculate_whatif_monthly_cashflow( to_decimal(rehab_estimate) / Decimal(12) if rehab_estimate else Decimal("0") ) return monthly_income - additional_monthly - rehab_monthly - - -# ── Convenience aliases with calculate_ prefix ───────────────────────────────── -# These satisfy the Jumpstart requirement for explicitly-named calculate_* functions. - - -def calculate_noi( - gross_income: Decimal, - operating_expenses: Decimal, -) -> Decimal: - """Calculate Net Operating Income (NOI). - - NOI = Gross Income - Operating Expenses - - Args: - gross_income: Total annual rental and other income from the property. - operating_expenses: Total annual operating expenses (excluding debt service). - - Returns: - Net Operating Income as a Decimal. - """ - return to_decimal(gross_income) - to_decimal(operating_expenses) - - -def calculate_cap_rate( - annual_noi: Decimal, - property_value: Decimal, -) -> Decimal: - """Calculate Capitalization Rate. - - Cap Rate = NOI / Property Value - - Args: - annual_noi: Net Operating Income. - property_value: Current market value or purchase price of the property. - - Returns: - Capitalization rate as a Decimal (e.g., 0.08 for 8%). - - Raises: - ValueError: If property_value is zero. - """ - pv = to_decimal(property_value) - if pv == Decimal("0"): - raise ValueError("Property value cannot be zero") - return (to_decimal(annual_noi) / pv).quantize(Decimal("0.0001")) - - -def calculate_cash_on_cash( - annual_cash_flow: Decimal, - total_cash_invested: Decimal, -) -> Decimal: - """Calculate Cash-on-Cash Return. - - Cash-on-Cash = Annual Cash Flow / Total Cash Invested - - Args: - annual_cash_flow: Annual pre-tax cash flow from the investment. - total_cash_invested: Total cash invested (down payment + closing costs). - - Returns: - Cash-on-cash return as a Decimal (e.g., 0.10 for 10%). - - Raises: - ValueError: If total_cash_invested is zero. - """ - tci = to_decimal(total_cash_invested) - if tci == Decimal("0"): - raise ValueError("Total cash invested cannot be zero") - return (to_decimal(annual_cash_flow) / tci).quantize(Decimal("0.0001")) - - -def calculate_irr(cash_flows: Sequence[float | int | Decimal]) -> Decimal: - """Calculate Internal Rate of Return (IRR). - - Args: - cash_flows: Sequence of cash flows, starting with the initial investment - (typically negative) followed by periodic returns. Accepts int, float, - or Decimal values; each value is coerced to float internally for - numpy-financial. - - Returns: - IRR as a Decimal (e.g., 0.15 for 15%). - - Raises: - ValueError: If fewer than 2 cash flows are supplied or IRR cannot be computed. - """ - if len(cash_flows) < 2: - raise ValueError("At least 2 cash flows are required to calculate IRR") - normalized_cash_flows = [to_decimal(cf) for cf in cash_flows] - result = irr(normalized_cash_flows) - if result == Decimal("0") and all( - cf >= Decimal("0") for cf in normalized_cash_flows - ): - raise ValueError("IRR could not be computed for the given cash flows") - return result - - -# ── Depreciation & Tax Modeling ──────────────────────────────────────────────── - - -def annual_depreciation(purchase_price: Decimal, land_value: Decimal) -> Decimal: - """Calculate the annual straight-line depreciation for a residential rental property. - - The IRS allows 27.5-year straight-line depreciation on the building portion - (purchase price minus land value) of residential rental property. - - Args: - purchase_price: Total purchase price of the property (must be > 0). - land_value: Estimated value of the land component (must be >= 0 and - < purchase_price). Land is not depreciable. - - Returns: - Annual depreciation deduction as a Decimal representing the fixed deduction - for a full year. Year-by-year schedule handling is the caller's responsibility: - apply this amount for years 1–27 (full deduction), half this amount for year 28 - (remaining half-year fraction), and no deduction for years beyond year 28. - - Raises: - ValueError: If purchase_price <= 0. - ValueError: If land_value < 0. - ValueError: If land_value >= purchase_price (no depreciable basis). - - Example: - >>> annual_depreciation(Decimal("300000"), Decimal("50000")) - Decimal("9090.909090909090909090909091") - """ - pp = to_decimal(purchase_price) - lv = to_decimal(land_value) - - if pp <= Decimal("0"): - raise ValueError("purchase_price must be greater than zero") - if lv < Decimal("0"): - raise ValueError("land_value must be zero or greater") - if lv >= pp: - raise ValueError( - "land_value must be less than purchase_price; land is not depreciable" - ) - - depreciable_basis = pp - lv - return depreciable_basis / Decimal("27.5") - - -def after_tax_cash_flow( - noi: Decimal, - annual_debt_service: Decimal, - depreciation_deduction: Decimal, - marginal_tax_rate: Decimal, -) -> Decimal: - """Calculate after-tax cash flow including the depreciation tax shield. - - Formula: (NOI - debt_service) + (depreciation × tax_rate) - - The depreciation tax shield represents the tax savings from the paper loss of - depreciation, which reduces taxable income without a cash outflow. - - Args: - noi: Net Operating Income (annual). - annual_debt_service: Total annual mortgage payments (principal + interest). - depreciation_deduction: Annual depreciation deduction (e.g., from - ``annual_depreciation()``). - marginal_tax_rate: Investor's marginal income tax rate as a decimal in [0, 1] - (e.g., 0.24 for 24%). - - Returns: - After-tax cash flow as a Decimal. A positive value indicates net cash benefit. - - Raises: - ValueError: If marginal_tax_rate is outside the range [0, 1]. - - Example: - >>> after_tax_cash_flow( - ... Decimal("24000"), Decimal("18000"), Decimal("9091"), Decimal("0.24") - ... ) - Decimal("8181.84") - """ - rate = to_decimal(marginal_tax_rate) - if rate < Decimal("0") or rate > Decimal("1"): - raise ValueError( - "marginal_tax_rate must be between 0 and 1 inclusive " - f"(received {marginal_tax_rate})" - ) - - pre_tax_cf = to_decimal(noi) - to_decimal(annual_debt_service) - tax_shield = to_decimal(depreciation_deduction) * rate - return pre_tax_cf + tax_shield - - -def after_tax_irr( - cash_flows: Sequence[Decimal], - depreciation_schedule: Sequence[Decimal], - marginal_tax_rate: Decimal, -) -> Decimal: - """Calculate after-tax IRR by adjusting each period's cash flow by the depreciation tax shield. - - Each period's cash flow is increased by ``depreciation × marginal_tax_rate``. - The first cash flow (index 0) is assumed to be the initial investment (negative) - and is not adjusted — depreciation tax shields begin in period 1. - - Args: - cash_flows: List of periodic cash flows. Index 0 is typically the initial - investment (negative). Must have at least 2 elements. - depreciation_schedule: List of annual depreciation amounts aligned to - cash_flows[1:]. If shorter than cash_flows[1:], missing periods are - treated as zero depreciation. - marginal_tax_rate: Investor's marginal income tax rate as a decimal in [0, 1]. - - Returns: - After-tax IRR as a Decimal. Returns Decimal("0") if numpy-financial cannot - converge (e.g., all non-negative flows or no sign change). - - Raises: - ValueError: If fewer than 2 cash flows are supplied. - ValueError: If marginal_tax_rate is outside the range [0, 1]. - - Example: - >>> after_tax_irr( - ... [Decimal("-100000"), Decimal("6000"), Decimal("106000")], - ... [Decimal("9091"), Decimal("9091")], - ... Decimal("0.24"), - ... ) - Decimal("0.0718") - """ - if len(cash_flows) < 2: - raise ValueError("At least 2 cash flows are required to calculate IRR") - - rate = to_decimal(marginal_tax_rate) - if rate < Decimal("0") or rate > Decimal("1"): - raise ValueError( - "marginal_tax_rate must be between 0 and 1 inclusive " - f"(received {marginal_tax_rate})" - ) - - # Build adjusted cash flows: index 0 (initial investment) is not adjusted. - adjusted: list[float] = [float(cash_flows[0])] - for i, cf in enumerate(cash_flows[1:]): - dep = ( - depreciation_schedule[i] if i < len(depreciation_schedule) else Decimal("0") - ) - shield = to_decimal(dep) * rate - adjusted.append(float(to_decimal(cf) + shield)) - - cf_array = np.array(adjusted, dtype=float) - try: - value = float(npf.irr(cf_array)) - if np.isnan(value) or np.isinf(value): - logger.warning( - "after_tax_irr: numpy_financial.irr returned non-finite value; returning 0" - ) - return Decimal("0") - return to_decimal(value) - except Exception as exc: - logger.warning("after_tax_irr: numpy_financial.irr raised %s; returning 0", exc) - return Decimal("0") - - -# ── Hold Period & Exit Analysis ──────────────────────────────────────────────── - - -def project_annual_cash_flows( - gross_rent_year1: Decimal, - operating_expense_year1: Decimal, - annual_debt_service: Decimal, - rent_growth_rate: Decimal, - expense_growth_rate: Decimal, - hold_years: int, -) -> list[Decimal]: - """Project year-by-year after-debt-service cash flows over a hold period. - - Each year's gross rent and operating expenses grow independently at their - respective compound annual growth rates. Annual debt service is assumed - constant (fixed-rate mortgage). - - Formula per year ``n`` (1-indexed): - gross_rent(n) = gross_rent_year1 × (1 + rent_growth_rate)^(n-1) - oper_expense(n) = operating_expense_year1 × (1 + expense_growth_rate)^(n-1) - NOI(n) = gross_rent(n) - oper_expense(n) - cash_flow(n) = NOI(n) - annual_debt_service - - Args: - gross_rent_year1: Gross rental income in year 1 (must be >= 0). - operating_expense_year1: Operating expenses in year 1 (must be >= 0). - annual_debt_service: Fixed annual mortgage payment (principal + interest; - must be >= 0). - rent_growth_rate: Annual rent growth rate as a decimal (e.g., 0.03 for 3%). - Must be in the range [-0.5, 0.5]. - expense_growth_rate: Annual expense growth rate as a decimal. - Must be in the range [-0.5, 0.5]. - hold_years: Number of years in the hold period. Must be in [1, 50]. - - Returns: - List of annual cash-flow Decimals, one entry per year (length == hold_years). - Negative values indicate years where debt service exceeds NOI. - - Raises: - ValueError: If ``gross_rent_year1`` or ``operating_expense_year1`` or - ``annual_debt_service`` is negative. - ValueError: If ``hold_years`` is outside [1, 50]. - ValueError: If ``rent_growth_rate`` or ``expense_growth_rate`` is outside - [-0.5, 0.5]. - - Example: - >>> flows = project_annual_cash_flows( - ... Decimal("36000"), Decimal("12000"), Decimal("18000"), - ... Decimal("0.03"), Decimal("0.02"), 5, - ... ) - >>> len(flows) - 5 - """ - if hold_years < 1 or hold_years > 50: - raise ValueError(f"hold_years must be between 1 and 50 (received {hold_years})") - - r_rate = to_decimal(rent_growth_rate) - e_rate = to_decimal(expense_growth_rate) - rate_limit = Decimal("0.5") - if r_rate < -rate_limit or r_rate > rate_limit: - raise ValueError( - f"rent_growth_rate must be in [-0.5, 0.5] (received {rent_growth_rate})" - ) - if e_rate < -rate_limit or e_rate > rate_limit: - raise ValueError( - f"expense_growth_rate must be in [-0.5, 0.5] (received {expense_growth_rate})" - ) - - rent = to_decimal(gross_rent_year1) - expense = to_decimal(operating_expense_year1) - debt = to_decimal(annual_debt_service) - - if rent < Decimal("0"): - raise ValueError( - f"gross_rent_year1 must be zero or greater (received {gross_rent_year1})" - ) - if expense < Decimal("0"): - raise ValueError( - f"operating_expense_year1 must be zero or greater (received {operating_expense_year1})" - ) - if debt < Decimal("0"): - raise ValueError( - f"annual_debt_service must be zero or greater (received {annual_debt_service})" - ) - - cash_flows: list[Decimal] = [] - one = Decimal("1") - for year in range(1, hold_years + 1): - exponent = year - 1 - gross = rent * (one + r_rate) ** exponent - opex = expense * (one + e_rate) ** exponent - annual_noi = gross - opex - cash_flows.append(annual_noi - debt) - - return cash_flows - - -def project_property_value( - purchase_price: Decimal, - appreciation_rate: Decimal, - hold_years: int, -) -> Decimal: - """Project the market value of a property at the end of a hold period. - - Uses compound annual growth: - value = purchase_price × (1 + appreciation_rate)^hold_years - - Supports conservative / base / optimistic scenarios by varying - ``appreciation_rate`` (e.g., 0%, 3%, 5% for US residential). - - Args: - purchase_price: Original purchase price of the property (must be > 0). - appreciation_rate: Expected annual appreciation rate as a decimal. - Must be >= -1 (a rate of -1 implies a total loss of value; rates - below -1 are mathematically undefined for this formula). - hold_years: Number of years to project forward (must be in [1, 50]). - - Returns: - Projected property value as a Decimal. - - Raises: - ValueError: If ``purchase_price`` <= 0. - ValueError: If ``appreciation_rate`` < -1. - ValueError: If ``hold_years`` is outside [1, 50]. - - Example: - >>> project_property_value(Decimal("300000"), Decimal("0.03"), 10) - Decimal("403175....") - """ - pp = to_decimal(purchase_price) - rate = to_decimal(appreciation_rate) - - if pp <= Decimal("0"): - raise ValueError( - f"purchase_price must be greater than zero (received {purchase_price})" - ) - if rate < Decimal("-1"): - raise ValueError( - f"appreciation_rate must be >= -1 (received {appreciation_rate})" - ) - if hold_years < 1 or hold_years > 50: - raise ValueError(f"hold_years must be between 1 and 50 (received {hold_years})") - - return pp * (Decimal("1") + rate) ** hold_years - - -def net_sale_proceeds( - sale_price: Decimal, - original_purchase_price: Decimal, - outstanding_loan_balance: Decimal, - accumulated_depreciation: Decimal, - agent_commission_rate: Decimal = Decimal("0.06"), - closing_cost_rate: Decimal = Decimal("0.01"), - long_term_cg_rate: Decimal = Decimal("0.15"), - depreciation_recapture_rate: Decimal = Decimal("0.25"), -) -> Decimal: - """Calculate net cash to investor after costs and taxes upon property sale. - - Deductions applied in order: - 1. Agent commissions: ``sale_price × agent_commission_rate`` - 2. Closing costs: ``sale_price × closing_cost_rate`` - 3. Loan payoff: ``outstanding_loan_balance`` - 4. Capital gains tax: max(``sale_price - original_purchase_price``, 0) × ``long_term_cg_rate`` - (no capital gains tax if property sold at a loss) - 5. Depreciation recapture: ``accumulated_depreciation × depreciation_recapture_rate`` - - Args: - sale_price: Gross sale price of the property. - original_purchase_price: Price paid for the property at acquisition. - outstanding_loan_balance: Remaining mortgage balance at time of sale - (must be >= 0). - accumulated_depreciation: Total depreciation taken over the holding period - (must be >= 0). - agent_commission_rate: Broker commission as a decimal (default 0.06 = 6%). - closing_cost_rate: Seller's closing costs as a decimal (default 0.01 = 1%). - long_term_cg_rate: Federal long-term capital gains tax rate as a decimal - (default 0.15 = 15%). - depreciation_recapture_rate: IRS Section 1250 recapture rate as a decimal - (default 0.25 = 25%). - - Returns: - Net cash proceeds to investor as a Decimal (may be negative if costs exceed - gross proceeds). - - Raises: - ValueError: If ``outstanding_loan_balance`` < 0. - ValueError: If ``accumulated_depreciation`` < 0. - ValueError: If any rate parameter is outside [0, 1]. - - Example: - >>> net_sale_proceeds( - ... Decimal("400000"), Decimal("300000"), Decimal("200000"), - ... Decimal("45000"), - ... ) - Decimal("...") - """ - sp = to_decimal(sale_price) - opp = to_decimal(original_purchase_price) - loan_bal = to_decimal(outstanding_loan_balance) - acc_dep = to_decimal(accumulated_depreciation) - commission_rate = to_decimal(agent_commission_rate) - cc_rate = to_decimal(closing_cost_rate) - cg_rate = to_decimal(long_term_cg_rate) - recapture_rate = to_decimal(depreciation_recapture_rate) - - if loan_bal < Decimal("0"): - raise ValueError( - f"outstanding_loan_balance must be zero or greater (received {outstanding_loan_balance})" - ) - if acc_dep < Decimal("0"): - raise ValueError( - f"accumulated_depreciation must be zero or greater (received {accumulated_depreciation})" - ) - for name, val in [ - ("agent_commission_rate", commission_rate), - ("closing_cost_rate", cc_rate), - ("long_term_cg_rate", cg_rate), - ("depreciation_recapture_rate", recapture_rate), - ]: - if val < Decimal("0") or val > Decimal("1"): - raise ValueError( - f"{name} must be between 0 and 1 inclusive (received {val})" - ) - - gross_proceeds = sp - sp * commission_rate - sp * cc_rate - loan_bal - - capital_gain = sp - opp - cg_tax = max(capital_gain, Decimal("0")) * cg_rate - - recapture_tax = acc_dep * recapture_rate - - return gross_proceeds - cg_tax - recapture_tax - - -def total_return_summary( - purchase_price: Decimal, - down_payment: Decimal, - annual_cash_flows: list[Decimal], - net_sale_proceeds_amount: Decimal, -) -> Dict[str, Decimal]: - """Summarise total investment return over the hold period. - - Combines cumulative cash flows and net sale proceeds to compute total return - metrics. IRR is computed using the full cash-flow series: - - Year 0: ``-down_payment`` (initial equity outlay) - - Years 1…N: ``annual_cash_flows`` - - Year N: ``annual_cash_flows[-1] + net_sale_proceeds_amount`` (exit year) - - Args: - purchase_price: Original acquisition price of the property. Included in - the returned summary dict as ``"purchase_price"`` for caller convenience. - down_payment: Equity invested at purchase (positive value; used as the - year-0 outflow). - annual_cash_flows: List of annual after-debt-service cash flows from - ``project_annual_cash_flows()``. Must have at least 1 element. - net_sale_proceeds_amount: Net cash to investor upon sale from - ``net_sale_proceeds()``. - - Returns: - Dictionary with the following keys: - - - ``purchase_price`` (Decimal): The ``purchase_price`` argument. - - ``total_cash_flow`` (Decimal): Sum of ``annual_cash_flows``. - - ``net_sale_proceeds`` (Decimal): The ``net_sale_proceeds_amount`` argument. - - ``total_return`` (Decimal): ``total_cash_flow + net_sale_proceeds``. - - ``total_return_on_equity`` (Decimal): ``total_return / down_payment``, or - ``Decimal("0")`` if ``down_payment`` is zero. - - ``annualized_irr`` (Decimal): IRR computed via ``irr()`` over the full - cash-flow series. - - Raises: - ValueError: If ``annual_cash_flows`` is empty. - ValueError: If ``down_payment`` < 0. - - Example: - >>> summary = total_return_summary( - ... Decimal("300000"), Decimal("60000"), - ... [Decimal("6000")] * 10, Decimal("120000"), - ... ) - >>> summary["total_cash_flow"] - Decimal("60000") - """ - if not annual_cash_flows: - raise ValueError("annual_cash_flows must contain at least one element") - - dp = to_decimal(down_payment) - if dp < Decimal("0"): - raise ValueError( - f"down_payment must be zero or greater (received {down_payment})" - ) - - total_cf = sum(annual_cash_flows, Decimal("0")) - nsp = to_decimal(net_sale_proceeds_amount) - total_ret = total_cf + nsp - - if dp == Decimal("0"): - roe = Decimal("0") - else: - roe = total_ret / dp - - # Build IRR cash-flow series: year-0 outflow, annual CFs, exit-year bump - irr_flows: list[Decimal] = [-dp] - for i, cf in enumerate(annual_cash_flows): - if i == len(annual_cash_flows) - 1: - irr_flows.append(cf + nsp) - else: - irr_flows.append(cf) - - annualized = irr(irr_flows) - - return { - "purchase_price": to_decimal(purchase_price), - "total_cash_flow": total_cf, - "net_sale_proceeds": nsp, - "total_return": total_ret, - "total_return_on_equity": roe, - "annualized_irr": annualized, - } - - -def depreciation_recapture_tax( - accumulated_depreciation: Decimal, - recapture_rate: Decimal = Decimal("0.25"), -) -> Decimal: - """Calculate the depreciation recapture tax owed upon sale of the property. - - Under IRS Section 1250, accumulated depreciation is recaptured at a maximum - rate of 25% when the property is sold. This tax is owed regardless of whether - the property sold at a gain or loss on paper. - - Args: - accumulated_depreciation: Total depreciation taken over the holding period - (sum of annual deductions). Must be >= 0. - recapture_rate: IRS Section 1250 recapture rate as a decimal in [0, 1]. - Defaults to 0.25 (25%). - - Returns: - Depreciation recapture tax owed as a Decimal. - - Raises: - ValueError: If accumulated_depreciation < 0. - ValueError: If recapture_rate is outside [0, 1]. - - Example: - >>> depreciation_recapture_tax(Decimal("45000")) - Decimal("11250.00") - """ - acc_dep = to_decimal(accumulated_depreciation) - rate = to_decimal(recapture_rate) - - if acc_dep < Decimal("0"): - raise ValueError("accumulated_depreciation must be zero or greater") - if rate < Decimal("0") or rate > Decimal("1"): - raise ValueError( - "recapture_rate must be between 0 and 1 inclusive " - f"(received {recapture_rate})" - ) - - return acc_dep * rate - - -# ── Underwriting Score v2 ────────────────────────────────────────────────────── - -# Scoring thresholds for composite score sub-components. -# Cap rate is scaled so that a deal exactly 5 pp above market scores 100; -# 5 pp was chosen as a practical "outstanding" cap-rate premium for US rental markets. -_CAP_RATE_SCALE_THRESHOLD = Decimal("0.05") - -# CoC is scaled so that a 20% year-1 cash-on-cash return scores 100; -# 20% is a well-established benchmark for strong passive income investments. -_COC_SCALE_THRESHOLD = Decimal("0.20") - - -class ScoreV2Result(TypedDict): - """Return type for :func:`score_listing_v2`.""" - - one_percent_rule_pass: bool - grm: Decimal - cap_rate: Decimal - cap_rate_vs_market: Decimal - coc_year1: Decimal - composite_score: Decimal - - -def one_percent_rule(monthly_rent: Decimal, purchase_price: Decimal) -> bool: - """Evaluate the 1% Rule for a rental property. - - The 1% Rule is a quick pass/fail filter: monthly rent should be at least - 1% of the purchase price to indicate a potentially viable rental investment. - - Derivation: it's a rule-of-thumb proxy for gross yield (monthly_rent x 12 - / purchase_price >= 0.12) rescaled to a monthly figure so it can be - screened without annualizing - a fast pre-filter, not a substitute for - cap rate or CoC. - - Args: - monthly_rent: Expected gross monthly rental income. - purchase_price: Total purchase price of the property. - - Returns: - True if monthly_rent / purchase_price >= 0.01, False otherwise. - - Raises: - ValueError: If purchase_price is zero or negative. - """ - pp = to_decimal(purchase_price) - if pp <= Decimal("0"): - raise ValueError( - f"purchase_price must be greater than zero (received {purchase_price})" - ) - return to_decimal(monthly_rent) / pp >= Decimal("0.01") - - -def gross_rent_multiplier(purchase_price: Decimal, annual_rent: Decimal) -> Decimal: - """Calculate Gross Rent Multiplier (GRM). - - GRM = Purchase Price / Annual Rent - - Lower GRM values indicate better value relative to rental income. - Typical benchmarks: < 10 excellent, 10–15 good, 15–20 fair, > 20 poor. - - Derivation: GRM is the reciprocal-scaled inverse of a rent yield - (price / rent, vs. cap rate's noi / price) - a valuation multiple - analogous to a price-to-revenue ratio, using gross rent rather than - NOI so it can be computed without first estimating operating expenses. - - Args: - purchase_price: Total purchase price of the property. - annual_rent: Expected gross annual rental income. - - Returns: - GRM as a Decimal. - - Raises: - ValueError: If annual_rent is zero or negative. - """ - ar = to_decimal(annual_rent) - if ar <= Decimal("0"): - raise ValueError( - f"annual_rent must be greater than zero (received {annual_rent})" - ) - return to_decimal(purchase_price) / ar - - -def price_to_rent_ratio( - median_home_price: Decimal, annual_median_rent: Decimal -) -> Decimal: - """Calculate market price-to-rent ratio. - - Args: - median_home_price: Median home purchase price. - annual_median_rent: Median annual rent. - - Returns: - Price-to-rent ratio as a Decimal. - - Raises: - ValueError: If annual_median_rent is zero or negative. - """ - annual_rent = to_decimal(annual_median_rent) - if annual_rent <= Decimal("0"): - raise ValueError( - "annual_median_rent must be greater than zero " - f"(received {annual_median_rent})" - ) - return to_decimal(median_home_price) / annual_rent - - -_EXCELLENT_PRICE_TO_RENT_THRESHOLD = Decimal("15") -_NEUTRAL_PRICE_TO_RENT_THRESHOLD = Decimal("20") -_MAX_PRICE_TO_RENT_THRESHOLD = Decimal("30") -_HIGH_SCORE_FLOOR = Decimal("60") -_HIGH_SCORE_RANGE = Decimal("40") -_LOW_SCORE_RANGE = Decimal("60") - -_MIN_GROWTH_RATE_PERCENT = Decimal("-5") -_MAX_GROWTH_RATE_PERCENT = Decimal("10") -_GROWTH_RATE_RANGE = _MAX_GROWTH_RATE_PERCENT - _MIN_GROWTH_RATE_PERCENT - - -def normalize_market_price_to_rent_score(price_to_rent: Decimal) -> Decimal: - """Convert price-to-rent ratio into a 0-100 market sub-score. - - Args: - price_to_rent: Price-to-rent ratio for a market. - - Returns: - Market sub-score in [0, 100], where higher is better. - - Scoring logic: - - ``price_to_rent < 15`` -> ``100`` - - ``15 <= price_to_rent <= 20`` -> linearly mapped from ``100`` down to ``60`` - - ``20 < price_to_rent <= 30`` -> linearly mapped from ``60`` down to ``0`` - - ``price_to_rent > 30`` -> ``0`` - """ - if price_to_rent <= Decimal("0"): - return Decimal("0") - if price_to_rent < _EXCELLENT_PRICE_TO_RENT_THRESHOLD: - return Decimal("100") - if price_to_rent <= _NEUTRAL_PRICE_TO_RENT_THRESHOLD: - return (_NEUTRAL_PRICE_TO_RENT_THRESHOLD - price_to_rent) / ( - _NEUTRAL_PRICE_TO_RENT_THRESHOLD - _EXCELLENT_PRICE_TO_RENT_THRESHOLD - ) * _HIGH_SCORE_RANGE + _HIGH_SCORE_FLOOR - if price_to_rent <= _MAX_PRICE_TO_RENT_THRESHOLD: - return ( - (_MAX_PRICE_TO_RENT_THRESHOLD - price_to_rent) - / (_MAX_PRICE_TO_RENT_THRESHOLD - _NEUTRAL_PRICE_TO_RENT_THRESHOLD) - * _LOW_SCORE_RANGE - ) - return Decimal("0") - - -def normalize_market_growth_rate_score(growth_rate: Decimal) -> Decimal: - """Convert annual growth rate percent into a 0-100 market sub-score. - - Args: - growth_rate: Annual growth rate as a percent value. - - Returns: - Market sub-score in [0, 100], where higher is better. - - Growth values are clamped to ``[-5, 10]`` percent and then linearly - mapped to ``[0, 100]``. - """ - clamped = max(_MIN_GROWTH_RATE_PERCENT, min(_MAX_GROWTH_RATE_PERCENT, growth_rate)) - return (clamped - _MIN_GROWTH_RATE_PERCENT) / _GROWTH_RATE_RANGE * Decimal("100") - - -def clamp_market_score(value: Decimal) -> Decimal: - """Clamp a market score to the valid 0-100 range. - - Args: - value: Raw market score value. - - Returns: - Score clamped to [0, 100]. - """ - return max(Decimal("0"), min(Decimal("100"), value)) - - -def _grm_score(grm: Decimal) -> Decimal: - """Map a GRM value to a 0–100 sub-score using published heuristics. - - Heuristics: GRM < 10 → excellent (100), 10–15 → good (75), - 15–20 → fair (40), > 20 → poor (10). - - Args: - grm: Gross Rent Multiplier value. - - Returns: - Sub-score as a Decimal in [0, 100]. - """ - if grm < Decimal("10"): - return Decimal("100") - if grm < Decimal("15"): - return Decimal("75") - if grm < Decimal("20"): - return Decimal("40") - return Decimal("10") - - -def score_listing_v2( - purchase_price: Decimal, - monthly_rent: Decimal, - annual_noi: Decimal, - local_market_cap_rate: Decimal, - down_payment: Decimal, - annual_debt_service: Decimal, -) -> ScoreV2Result: - """Compute an investor-grade multi-signal underwriting score for a rental listing. - - Signals and weights (hardcoded; future versions should read from - settings.UNDERWRITING_SCORE_WEIGHTS): - - 1% Rule pass/fail : 20% - - Cap rate vs. market : 30% - - CoC Year 1 : 30% - - GRM heuristic : 20% - - A deal that fails the 1% Rule has its composite_score capped at 40. - - Args: - purchase_price: Total purchase price of the property. Must be > 0. - monthly_rent: Expected gross monthly rental income. Must be > 0. - annual_noi: Net Operating Income for Year 1. Must be > 0. - local_market_cap_rate: Prevailing cap rate for the market as a decimal - (e.g., Decimal("0.06") for 6%). Must be > 0. - down_payment: Cash down payment (total cash invested). Must be > 0. - annual_debt_service: Total annual principal + interest payments. Must be > 0. - - Returns: - Dict with keys: - one_percent_rule_pass (bool): True if monthly_rent / purchase_price >= 1%. - grm (Decimal): Gross Rent Multiplier. - cap_rate (Decimal): cap rate = annual_noi / purchase_price. - cap_rate_vs_market (Decimal): cap_rate minus local_market_cap_rate; - positive means above-market (better). - coc_year1 (Decimal): Cash-on-Cash return for Year 1. - composite_score (Decimal): Weighted score in [0, 100]. - - Raises: - ValueError: If any of purchase_price, monthly_rent, annual_noi, - local_market_cap_rate, down_payment, or annual_debt_service is <= 0. - """ - # -- Input validation ------------------------------------------------------- - inputs = { - "purchase_price": purchase_price, - "monthly_rent": monthly_rent, - "annual_noi": annual_noi, - "local_market_cap_rate": local_market_cap_rate, - "down_payment": down_payment, - "annual_debt_service": annual_debt_service, - } - for name, value in inputs.items(): - if to_decimal(value) <= Decimal("0"): - raise ValueError(f"{name} must be greater than zero (received {value})") - - # -- Individual signals ----------------------------------------------------- - pp = to_decimal(purchase_price) - mr = to_decimal(monthly_rent) - noi_val = to_decimal(annual_noi) - market_cap = to_decimal(local_market_cap_rate) - dp = to_decimal(down_payment) - ads = to_decimal(annual_debt_service) - - one_pct_pass = one_percent_rule(mr, pp) - grm = gross_rent_multiplier(pp, mr * Decimal("12")) - property_cap_rate = noi_val / pp - cap_rate_vs_market = property_cap_rate - market_cap - annual_cash_flow = noi_val - ads - coc_year1 = annual_cash_flow / dp - - # -- Sub-scores (each 0–100) ------------------------------------------------ - # 1% Rule: full points if pass, zero if fail - one_pct_sub = Decimal("100") if one_pct_pass else Decimal("0") - - # Cap rate vs market: scale so +5 pp above market = 100, -5 pp = 0 - # cap_rate_vs_market is a raw decimal difference (e.g. 0.02 = 2 pp above) - raw_cap_score = (cap_rate_vs_market / _CAP_RATE_SCALE_THRESHOLD) * Decimal("100") - cap_vs_market_sub = max(Decimal("0"), min(Decimal("100"), raw_cap_score)) - - # CoC Year 1: scale so 20% CoC = 100 points, 0% CoC = 0 points - raw_coc_score = coc_year1 / _COC_SCALE_THRESHOLD * Decimal("100") - coc_sub = max(Decimal("0"), min(Decimal("100"), raw_coc_score)) - - # GRM heuristic sub-score - grm_sub = _grm_score(grm) - - # -- Composite score -------------------------------------------------------- - # Weights: 1% rule 20%, cap vs market 30%, CoC 30%, GRM 20% - composite = ( - one_pct_sub * Decimal("0.20") - + cap_vs_market_sub * Decimal("0.30") - + coc_sub * Decimal("0.30") - + grm_sub * Decimal("0.20") - ) - - # Cap at 40 if 1% rule fails - if not one_pct_pass: - composite = min(Decimal("40"), composite) - - composite = max(Decimal("0"), min(Decimal("100"), composite)) - - return { - "one_percent_rule_pass": one_pct_pass, - "grm": grm, - "cap_rate": property_cap_rate, - "cap_rate_vs_market": cap_rate_vs_market, - "coc_year1": coc_year1, - "composite_score": composite, - } - - -# ── BRRRR Strategy ────────────────────────────────────────────────────────────── - - -def estimate_arv( - comparable_sales: list[tuple[Decimal, Decimal]], - subject_sqft: Decimal, -) -> Decimal: - """Estimate After-Repair Value (ARV) from comparable sales. - - Computes the median price-per-square-foot (PPSF) of the comparable sales - and multiplies it by the subject property's square footage. - - Args: - comparable_sales: List of ``(price, sqft)`` tuples, one per comparable - sale. Both ``price`` and ``sqft`` must be positive. - subject_sqft: Square footage of the subject property (must be > 0). - - Returns: - Estimated ARV as a Decimal. - - Raises: - ValueError: If ``comparable_sales`` is empty. - ValueError: If any comparable has ``price <= 0`` or ``sqft <= 0``. - ValueError: If ``subject_sqft <= 0``. - """ - if not comparable_sales: - raise ValueError("comparable_sales must not be empty") - - subject = to_decimal(subject_sqft) - if subject <= Decimal("0"): - raise ValueError( - f"subject_sqft must be greater than zero (received {subject_sqft})" - ) - - ppsf_values: list[Decimal] = [] - for idx, (price, sqft) in enumerate(comparable_sales): - p = to_decimal(price) - s = to_decimal(sqft) - if p <= Decimal("0"): - raise ValueError( - f"comparable_sales[{idx}]: price must be greater than zero (received {price})" - ) - if s <= Decimal("0"): - raise ValueError( - f"comparable_sales[{idx}]: sqft must be greater than zero (received {sqft})" - ) - ppsf_values.append(p / s) - - median_ppsf = to_decimal(median(ppsf_values)) - return median_ppsf * subject - - -def estimate_rehab_cost( - sqft: Decimal, - renovation_level: str, - cost_per_sqft: dict[str, Decimal], -) -> Decimal: - """Estimate total rehab cost for a property. - - Args: - sqft: Square footage of the property (must be > 0). - renovation_level: Scope of renovation. Must be one of the keys present - in ``cost_per_sqft`` (typically ``"cosmetic"``, ``"moderate"``, or - ``"full_gut"``). - cost_per_sqft: Mapping from renovation level to cost per square foot. - Supply ``settings.REHAB_COST_PER_SQFT`` from the service layer to - keep this function Django-free. - - Returns: - Estimated rehab cost as a Decimal. - - Raises: - ValueError: If ``renovation_level`` is not a key in ``cost_per_sqft``. - ValueError: If ``sqft <= 0``. - """ - valid_levels = set(cost_per_sqft.keys()) - if renovation_level not in valid_levels: - raise ValueError( - f"renovation_level must be one of {sorted(valid_levels)} " - f"(received {renovation_level!r})" - ) - s = to_decimal(sqft) - if s <= Decimal("0"): - raise ValueError(f"sqft must be greater than zero (received {sqft})") - - rate = to_decimal(cost_per_sqft[renovation_level]) - return rate * s - - -def max_refinance_loan( - arv: Decimal, - ltv_ratio: Decimal = Decimal("0.75"), -) -> Decimal: - """Calculate the maximum cash-out refinance loan amount at a given LTV. - - The conventional investment-property cash-out refinance limit (Fannie Mae) - is 75 % LTV. Expose ``ltv_ratio`` as a configurable parameter so callers - can model different lender requirements. - - Args: - arv: After-Repair Value of the property (must be > 0). - ltv_ratio: Loan-to-value ratio expressed as a decimal strictly between - 0 and 1 (e.g., ``Decimal("0.75")`` for 75 %). - - Returns: - Maximum refinance loan amount as a Decimal. - - Raises: - ValueError: If ``arv <= 0``. - ValueError: If ``ltv_ratio`` is not strictly in ``(0, 1)``. - """ - a = to_decimal(arv) - ltv = to_decimal(ltv_ratio) - - if a <= Decimal("0"): - raise ValueError(f"arv must be greater than zero (received {arv})") - if ltv <= Decimal("0") or ltv >= Decimal("1"): - raise ValueError( - f"ltv_ratio must be strictly between 0 and 1 (received {ltv_ratio})" - ) - - return a * ltv - - -def cash_left_in_deal( - purchase_price: Decimal, - rehab_cost: Decimal, - cash_out_refi_amount: Decimal, - closing_costs: Decimal = Decimal("0"), -) -> Decimal: - """Calculate the investor's remaining cash deployed after a cash-out refinance. - - Formula:: - - cash_left = purchase_price + rehab_cost + closing_costs - cash_out_refi_amount - - A negative or zero result means the investor has recouped all invested capital - (the "infinite CoC" scenario in BRRRR terminology). - - Args: - purchase_price: Purchase price of the property. - rehab_cost: Total rehabilitation cost. - cash_out_refi_amount: Proceeds from the cash-out refinance. - closing_costs: Total closing costs (purchase + refi combined). Defaults - to ``Decimal("0")``. - - Returns: - Cash left in the deal as a Decimal. Negative or zero ⇒ infinite CoC. - """ - return ( - to_decimal(purchase_price) - + to_decimal(rehab_cost) - + to_decimal(closing_costs) - - to_decimal(cash_out_refi_amount) - ) - - -def brrrr_coc_return( - annual_net_cash_flow: Decimal, - cash_left_in_deal: Decimal, -) -> Decimal: - """Calculate Cash-on-Cash return for a BRRRR deal. - - Handles the "infinite CoC" scenario where the investor has recouped all - (or more than all) of their capital. - - Rules: - * ``cash_left_in_deal <= 0`` → returns ``Decimal("Infinity")`` regardless - of cash flow (investor has no capital remaining in the deal). - * ``cash_left_in_deal > 0`` and ``annual_net_cash_flow == 0`` → returns - ``Decimal("0")`` (no return on remaining capital). - * Otherwise → returns ``annual_net_cash_flow / cash_left_in_deal``. - - Args: - annual_net_cash_flow: Annual after-debt-service cash flow (can be - negative for a losing deal). - cash_left_in_deal: Capital still deployed after the cash-out refi - (from ``cash_left_in_deal()``). - - Returns: - CoC return as a Decimal. ``Decimal("Infinity")`` signals infinite CoC. - """ - left = to_decimal(cash_left_in_deal) - flow = to_decimal(annual_net_cash_flow) - - if left <= Decimal("0"): - return Decimal("Infinity") - if flow == Decimal("0"): - return Decimal("0") - return flow / left - - -# ── Simplified Depreciation & After-Tax Calculations ───────────────────────── -# These are user-friendly wrappers around the lower-level functions above. -# They accept a land-value percentage instead of an absolute land value, and -# take a single pre-tax cash flow figure rather than NOI / debt-service args. - - -def calculate_annual_depreciation( - purchase_price: Decimal, - land_value_pct: Decimal = Decimal("0.20"), -) -> Decimal: - """Calculate annual straight-line depreciation for residential real estate. - - Uses the IRS 27.5-year straight-line schedule on the improvement value - (purchase price minus land). - - Args: - purchase_price: Total purchase price of the property (must be > 0). - land_value_pct: Fraction of purchase price attributable to land, - expressed as a decimal (e.g. ``Decimal("0.20")`` for 20 %). - Must be in the range [0, 1). Defaults to 0.20 (20 %), a - reasonable conservative estimate for most US residential properties. - - Returns: - Annual depreciation deduction as a Decimal. - - Raises: - ValueError: If ``purchase_price`` <= 0. - ValueError: If ``land_value_pct`` is outside [0, 1). - - Example: - >>> calculate_annual_depreciation(Decimal("200000"), Decimal("0.20")) - Decimal("5818.181818181818181818181818") - """ - pp = to_decimal(purchase_price) - lvp = to_decimal(land_value_pct) - - if pp <= Decimal("0"): - raise ValueError( - f"purchase_price must be greater than zero (received {purchase_price})" - ) - if lvp < Decimal("0") or lvp >= Decimal("1"): - raise ValueError( - f"land_value_pct must be in [0, 1) (received {land_value_pct})" - ) - - improvement_value = pp * (Decimal("1") - lvp) - return improvement_value / Decimal("27.5") - - -def calculate_after_tax_cashflow( - pre_tax_annual_cashflow: Decimal, - annual_depreciation: Decimal, - marginal_tax_rate: Decimal, -) -> Decimal: - """Calculate after-tax cash flow including the depreciation tax shield. - - This is a simplified model that does **not** account for passive activity - loss (PAL) rules, cost segregation, or other advanced tax strategies. - A UI disclaimer should note this limitation. - - Formula:: - - taxable_income = pre_tax_annual_cashflow - annual_depreciation - - if taxable_income < 0: # paper loss - tax_savings = abs(taxable_income) × marginal_tax_rate - after_tax = pre_tax_annual_cashflow + tax_savings - else: # taxable profit - tax_owed = taxable_income × marginal_tax_rate - after_tax = pre_tax_annual_cashflow - tax_owed - - Args: - pre_tax_annual_cashflow: Annual pre-tax cash flow from the property - (NOI minus debt service). Can be negative. - annual_depreciation: Annual depreciation deduction (e.g. from - :func:`calculate_annual_depreciation`). - marginal_tax_rate: Investor's marginal income-tax rate as a decimal - in [0, 1] (e.g. ``Decimal("0.32")`` for 32 %). - - Returns: - After-tax annual cash flow as a Decimal. - - Raises: - ValueError: If ``marginal_tax_rate`` is outside [0, 1]. - - Example: - >>> calculate_after_tax_cashflow( - ... Decimal("6000"), Decimal("5818"), Decimal("0.32") - ... ) - Decimal("5941.76") - """ - cashflow = to_decimal(pre_tax_annual_cashflow) - depreciation = to_decimal(annual_depreciation) - rate = to_decimal(marginal_tax_rate) - - if rate < Decimal("0") or rate > Decimal("1"): - raise ValueError( - f"marginal_tax_rate must be in [0, 1] (received {marginal_tax_rate})" - ) - - taxable_income = cashflow - depreciation - - if taxable_income < 0: - # Paper loss → tax savings (depreciation tax shield) - tax_savings = abs(taxable_income) * rate - return cashflow + tax_savings - else: - # Taxable profit → tax owed - tax_owed = taxable_income * rate - return cashflow - tax_owed diff --git a/tests/test_brrrr.py b/tests/test_brrrr.py index b303794d..7f96ed3a 100644 --- a/tests/test_brrrr.py +++ b/tests/test_brrrr.py @@ -12,7 +12,7 @@ import pytest -from investor_app.finance.utils import ( +from investor_app.finance.strategies import ( brrrr_coc_return, cash_left_in_deal, estimate_arv, diff --git a/tests/test_finance_math.py b/tests/test_finance_math.py index 5551d8e1..cf23fe16 100644 --- a/tests/test_finance_math.py +++ b/tests/test_finance_math.py @@ -16,17 +16,10 @@ import pytest # ── Production functions ─────────────────────────────────────────────────── -from investor_app.finance.utils import ( - annual_depreciation, - calculate_monthly_mortgage, - cap_rate, - cash_on_cash, - dscr, - gross_rent_multiplier, - irr, - noi, - one_percent_rule, -) +from investor_app.finance.mortgage import calculate_monthly_mortgage +from investor_app.finance.scoring import gross_rent_multiplier, one_percent_rule +from investor_app.finance.taxes import annual_depreciation +from investor_app.finance.utils import cap_rate, cash_on_cash, dscr, irr, noi # ── Reference implementations ────────────────────────────────────────────── from tests.finance_reference import ( diff --git a/tests/test_finance_utils.py b/tests/test_finance_utils.py index f9162a68..e4ba4c7c 100644 --- a/tests/test_finance_utils.py +++ b/tests/test_finance_utils.py @@ -4,175 +4,12 @@ import pytest -from investor_app.finance.utils import ( +from investor_app.finance.taxes import ( calculate_after_tax_cashflow, calculate_annual_depreciation, - calculate_cap_rate, - calculate_cash_on_cash, - calculate_irr, - calculate_noi, ) -class TestCalculateNoi: - """Tests for calculate_noi function.""" - - def test_positive_noi(self) -> None: - """Test NOI calculation with positive result.""" - gross_income = Decimal("120000") - operating_expenses = Decimal("40000") - result = calculate_noi(gross_income, operating_expenses) - assert result == Decimal("80000") - - def test_negative_noi(self) -> None: - """Test NOI calculation with negative result.""" - gross_income = Decimal("30000") - operating_expenses = Decimal("45000") - result = calculate_noi(gross_income, operating_expenses) - assert result == Decimal("-15000") - - def test_zero_noi(self) -> None: - """Test NOI calculation with zero result.""" - gross_income = Decimal("50000") - operating_expenses = Decimal("50000") - result = calculate_noi(gross_income, operating_expenses) - assert result == Decimal("0") - - def test_decimal_precision(self) -> None: - """Test that NOI preserves decimal precision.""" - gross_income = Decimal("100000.55") - operating_expenses = Decimal("33333.33") - result = calculate_noi(gross_income, operating_expenses) - assert result == Decimal("66667.22") - - -class TestCalculateCapRate: - """Tests for calculate_cap_rate function.""" - - def test_typical_cap_rate(self) -> None: - """Test cap rate calculation with typical values.""" - noi = Decimal("80000") - property_value = Decimal("1000000") - result = calculate_cap_rate(noi, property_value) - assert result == Decimal("0.08") - - def test_high_cap_rate(self) -> None: - """Test cap rate calculation with high return.""" - noi = Decimal("150000") - property_value = Decimal("1000000") - result = calculate_cap_rate(noi, property_value) - assert result == Decimal("0.15") - - def test_zero_property_value_raises_error(self) -> None: - """Test that zero property value raises ValueError.""" - noi = Decimal("80000") - property_value = Decimal("0") - with pytest.raises(ValueError, match="Property value cannot be zero"): - calculate_cap_rate(noi, property_value) - - def test_decimal_precision(self) -> None: - """Test that cap rate preserves decimal precision.""" - noi = Decimal("75000") - property_value = Decimal("1000000") - result = calculate_cap_rate(noi, property_value) - assert result == Decimal("0.075") - - -class TestCalculateCashOnCash: - """Tests for calculate_cash_on_cash function.""" - - def test_typical_cash_on_cash(self) -> None: - """Test cash-on-cash calculation with typical values.""" - annual_cash_flow = Decimal("20000") - total_cash_invested = Decimal("200000") - result = calculate_cash_on_cash(annual_cash_flow, total_cash_invested) - assert result == Decimal("0.1") - - def test_high_cash_on_cash(self) -> None: - """Test cash-on-cash calculation with high return.""" - annual_cash_flow = Decimal("50000") - total_cash_invested = Decimal("200000") - result = calculate_cash_on_cash(annual_cash_flow, total_cash_invested) - assert result == Decimal("0.25") - - def test_negative_cash_flow(self) -> None: - """Test cash-on-cash with negative cash flow.""" - annual_cash_flow = Decimal("-10000") - total_cash_invested = Decimal("200000") - result = calculate_cash_on_cash(annual_cash_flow, total_cash_invested) - assert result == Decimal("-0.05") - - def test_zero_cash_invested_raises_error(self) -> None: - """Test that zero cash invested raises ValueError.""" - annual_cash_flow = Decimal("20000") - total_cash_invested = Decimal("0") - with pytest.raises(ValueError, match="Total cash invested cannot be zero"): - calculate_cash_on_cash(annual_cash_flow, total_cash_invested) - - -class TestCalculateIrr: - """Tests for calculate_irr function.""" - - def test_positive_irr(self) -> None: - """Test IRR calculation with positive returns.""" - cash_flows = [ - Decimal("-100000"), - Decimal("30000"), - Decimal("35000"), - Decimal("40000"), - Decimal("45000"), - ] - result = calculate_irr(cash_flows) - # IRR should be approximately 15-20% - assert Decimal("0.10") < result < Decimal("0.25") - - def test_negative_irr(self) -> None: - """Test IRR calculation with negative returns.""" - cash_flows = [ - Decimal("-100000"), - Decimal("10000"), - Decimal("10000"), - Decimal("10000"), - ] - result = calculate_irr(cash_flows) - assert result < Decimal("0") - - def test_simple_irr(self) -> None: - """Test IRR with simple doubling investment.""" - # If you invest 100 and get 200 back in year 1, IRR = 100% - cash_flows = [Decimal("-100"), Decimal("200")] - result = calculate_irr(cash_flows) - assert abs(result - Decimal("1.0")) < Decimal("0.0001") - - def test_insufficient_cash_flows_raises_error(self) -> None: - """Test that fewer than 2 cash flows raises ValueError.""" - cash_flows = [Decimal("-100000")] - with pytest.raises(ValueError, match="At least 2 cash flows are required"): - calculate_irr(cash_flows) - - def test_empty_cash_flows_raises_error(self) -> None: - """Test that empty cash flows raises ValueError.""" - cash_flows: list[Decimal] = [] - with pytest.raises(ValueError, match="At least 2 cash flows are required"): - calculate_irr(cash_flows) - - def test_returns_decimal(self) -> None: - """Test that IRR returns a Decimal type.""" - cash_flows = [Decimal("-100"), Decimal("110")] - result = calculate_irr(cash_flows) - assert isinstance(result, Decimal) - - def test_all_positive_flows_raises_error(self) -> None: - """Test that all-positive cash flows (no sign change) raise ValueError. - - numpy_financial.irr returns nan for flows with no sign change; - calculate_irr should surface this as a ValueError. - """ - cash_flows = [Decimal("10000"), Decimal("20000"), Decimal("30000")] - with pytest.raises(ValueError, match="IRR could not be computed"): - calculate_irr(cash_flows) - - # ── Depreciation & After-Tax Cash Flow Tests ───────────────────────────────── diff --git a/tests/test_hold_period.py b/tests/test_hold_period.py index 756462ff..51a29330 100644 --- a/tests/test_hold_period.py +++ b/tests/test_hold_period.py @@ -4,7 +4,7 @@ import pytest -from investor_app.finance.utils import ( +from investor_app.finance.taxes import ( net_sale_proceeds, project_annual_cash_flows, project_property_value, diff --git a/tests/test_market_scoring.py b/tests/test_market_scoring.py index 7f1586b3..caf61cd2 100644 --- a/tests/test_market_scoring.py +++ b/tests/test_market_scoring.py @@ -7,7 +7,7 @@ from core.models import MarketSnapshot from core.services.market_scoring import score_market, update_market_scores -from investor_app.finance.utils import ( +from investor_app.finance.scoring import ( clamp_market_score, normalize_market_growth_rate_score, normalize_market_price_to_rent_score, diff --git a/tests/test_property_service.py b/tests/test_property_service.py deleted file mode 100644 index c0797f4e..00000000 --- a/tests/test_property_service.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for property service layer functions.""" - -from decimal import Decimal - -from core.services.property_service import calculate_noi - - -class TestCalculateNoi: - """Tests for calculate_noi in the service layer.""" - - def test_positive_noi(self) -> None: - result = calculate_noi(Decimal("120000"), Decimal("40000")) - assert result == Decimal("80000.00") - - def test_negative_noi(self) -> None: - result = calculate_noi(Decimal("30000"), Decimal("45000")) - assert result == Decimal("-15000.00") - - def test_zero_noi(self) -> None: - result = calculate_noi(Decimal("50000"), Decimal("50000")) - assert result == Decimal("0.00") - - def test_decimal_precision(self) -> None: - result = calculate_noi(Decimal("100000.55"), Decimal("33333.33")) - assert result == Decimal("66667.22") - - def test_large_values(self) -> None: - result = calculate_noi(Decimal("999999999.99"), Decimal("1.00")) - assert result == Decimal("999999998.99") - - def test_zero_income(self) -> None: - result = calculate_noi(Decimal("0"), Decimal("50000")) - assert result == Decimal("-50000.00") - - def test_zero_expenses(self) -> None: - result = calculate_noi(Decimal("75000"), Decimal("0")) - assert result == Decimal("75000.00") - - def test_all_zero(self) -> None: - result = calculate_noi(Decimal("0"), Decimal("0")) - assert result == Decimal("0.00") - - def test_quantized_to_two_places(self) -> None: - result = calculate_noi(Decimal("100"), Decimal("0.333")) - assert result == Decimal("99.67") diff --git a/tests/test_tax_analysis.py b/tests/test_tax_analysis.py index 55964093..8ccf3937 100644 --- a/tests/test_tax_analysis.py +++ b/tests/test_tax_analysis.py @@ -4,13 +4,13 @@ import pytest -from investor_app.finance.utils import ( +from investor_app.finance.taxes import ( after_tax_cash_flow, after_tax_irr, annual_depreciation, - calculate_irr, depreciation_recapture_tax, ) +from investor_app.finance.utils import irr class TestAnnualDepreciation: @@ -178,7 +178,7 @@ def test_zero_tax_rate_matches_pre_tax(self) -> None: dep_schedule = [Decimal("9091"), Decimal("9091")] after_tax = after_tax_irr(cash_flows, dep_schedule, Decimal("0")) # With 0% tax rate, no shield, so the cash flows are unchanged - pre_tax = calculate_irr(cash_flows) + pre_tax = irr(cash_flows) assert abs(after_tax - pre_tax) < Decimal("0.0001") def test_insufficient_cash_flows_raises(self) -> None: @@ -232,7 +232,7 @@ def test_empty_depreciation_schedule(self) -> None: """Test that an empty depreciation schedule applies no shields.""" cash_flows = [Decimal("-100000"), Decimal("55000"), Decimal("60000")] result = after_tax_irr(cash_flows, [], Decimal("0.24")) - pre_tax = calculate_irr(cash_flows) + pre_tax = irr(cash_flows) # No depreciation shield → should equal pre-tax IRR assert abs(result - pre_tax) < Decimal("0.0001") diff --git a/tests/test_underwriting_score.py b/tests/test_underwriting_score.py index 9b37eb61..9265b12a 100644 --- a/tests/test_underwriting_score.py +++ b/tests/test_underwriting_score.py @@ -1,16 +1,17 @@ -"""Tests for underwriting score v2 functions. +"""Tests for underwriting score v2 primitives. -Covers one_percent_rule, gross_rent_multiplier, and score_listing_v2. +Covers one_percent_rule and gross_rent_multiplier. The full underwriting +score is tested in core/tests/test_scoring_v2.py against the Django +implementation in core.services.scoring. """ from decimal import Decimal import pytest -from investor_app.finance.utils import ( +from investor_app.finance.scoring import ( gross_rent_multiplier, one_percent_rule, - score_listing_v2, ) # ── one_percent_rule ─────────────────────────────────────────────────────────── @@ -121,166 +122,3 @@ def test_returns_decimal(self) -> None: annual_rent=Decimal("24000"), ) assert isinstance(result, Decimal) - - -# ── score_listing_v2 ─────────────────────────────────────────────────────────── - - -class TestScoreListingV2: - """Tests for score_listing_v2.""" - - # -- Helpers ---------------------------------------------------------------- - - def _realistic_inputs(self, **overrides) -> dict: - """Return a baseline set of realistic inputs for score_listing_v2.""" - base = dict( - purchase_price=Decimal("250000"), - monthly_rent=Decimal("2500"), # 1% rule: 2500/250000 = 1.0% → pass - annual_noi=Decimal("18000"), # cap rate 7.2% - local_market_cap_rate=Decimal("0.06"), - down_payment=Decimal("62500"), # 25% down - annual_debt_service=Decimal("14400"), - ) - base.update(overrides) - return base - - # -- Smoke test / basic structure ------------------------------------------ - - def test_returns_expected_keys(self) -> None: - """score_listing_v2 should return a dict with all required keys.""" - result = score_listing_v2(**self._realistic_inputs()) - assert set(result.keys()) == { - "one_percent_rule_pass", - "grm", - "cap_rate", - "cap_rate_vs_market", - "coc_year1", - "composite_score", - } - - def test_realistic_composite_score_in_range(self) -> None: - """Composite score must be in [0, 100] for realistic inputs.""" - result = score_listing_v2(**self._realistic_inputs()) - score = result["composite_score"] - assert Decimal("0") <= score <= Decimal("100") - - def test_one_percent_rule_pass_reflected(self) -> None: - """one_percent_rule_pass should be True when rent >= 1% of price.""" - result = score_listing_v2(**self._realistic_inputs()) - assert result["one_percent_rule_pass"] is True - - def test_cap_rate_vs_market_positive_when_above(self) -> None: - """cap_rate_vs_market should be positive when property cap > local market cap.""" - result = score_listing_v2(**self._realistic_inputs()) - # 7.2% property cap vs 6% market → positive difference - assert result["cap_rate_vs_market"] > Decimal("0") - - # -- 1% rule failure caps composite score ---------------------------------- - - def test_failing_one_percent_rule_caps_score_at_40(self) -> None: - """A deal that fails the 1% rule must have composite_score <= 40.""" - # monthly_rent = 800 on a 250k property → 0.32% → fails - result = score_listing_v2(**self._realistic_inputs(monthly_rent=Decimal("800"))) - assert result["one_percent_rule_pass"] is False - assert result["composite_score"] <= Decimal("40") - - # -- Above-market vs. below-market cap rate -------------------------------- - - def test_above_market_cap_rate_raises_score_vs_below(self) -> None: - """A deal with a cap rate above market should score higher than one below market.""" - above = score_listing_v2( - **self._realistic_inputs(local_market_cap_rate=Decimal("0.04")) - ) # property cap 7.2% vs 4% market → above - below = score_listing_v2( - **self._realistic_inputs(local_market_cap_rate=Decimal("0.09")) - ) # property cap 7.2% vs 9% market → below - assert above["composite_score"] > below["composite_score"] - - # -- Boundary: very large purchase price ----------------------------------- - - def test_large_purchase_price(self) -> None: - """score_listing_v2 must handle a $10M property without raising.""" - result = score_listing_v2( - purchase_price=Decimal("10000000"), - monthly_rent=Decimal("100000"), # 1% of 10M → pass - annual_noi=Decimal("720000"), # 7.2% cap rate - local_market_cap_rate=Decimal("0.06"), - down_payment=Decimal("2500000"), - annual_debt_service=Decimal("576000"), - ) - assert Decimal("0") <= result["composite_score"] <= Decimal("100") - - # -- ValueError on invalid inputs ------------------------------------------ - - def test_zero_purchase_price_raises(self) -> None: - with pytest.raises(ValueError, match="purchase_price"): - score_listing_v2(**self._realistic_inputs(purchase_price=Decimal("0"))) - - def test_negative_purchase_price_raises(self) -> None: - with pytest.raises(ValueError, match="purchase_price"): - score_listing_v2(**self._realistic_inputs(purchase_price=Decimal("-1"))) - - def test_zero_monthly_rent_raises(self) -> None: - with pytest.raises(ValueError, match="monthly_rent"): - score_listing_v2(**self._realistic_inputs(monthly_rent=Decimal("0"))) - - def test_zero_annual_noi_raises(self) -> None: - with pytest.raises(ValueError, match="annual_noi"): - score_listing_v2(**self._realistic_inputs(annual_noi=Decimal("0"))) - - def test_zero_local_market_cap_rate_raises(self) -> None: - with pytest.raises(ValueError, match="local_market_cap_rate"): - score_listing_v2( - **self._realistic_inputs(local_market_cap_rate=Decimal("0")) - ) - - def test_zero_down_payment_raises(self) -> None: - with pytest.raises(ValueError, match="down_payment"): - score_listing_v2(**self._realistic_inputs(down_payment=Decimal("0"))) - - def test_zero_annual_debt_service_raises(self) -> None: - with pytest.raises(ValueError, match="annual_debt_service"): - score_listing_v2(**self._realistic_inputs(annual_debt_service=Decimal("0"))) - - def test_negative_monthly_rent_raises(self) -> None: - with pytest.raises(ValueError, match="monthly_rent"): - score_listing_v2(**self._realistic_inputs(monthly_rent=Decimal("-500"))) - - def test_negative_annual_noi_raises(self) -> None: - with pytest.raises(ValueError, match="annual_noi"): - score_listing_v2(**self._realistic_inputs(annual_noi=Decimal("-1000"))) - - def test_negative_local_market_cap_rate_raises(self) -> None: - with pytest.raises(ValueError, match="local_market_cap_rate"): - score_listing_v2( - **self._realistic_inputs(local_market_cap_rate=Decimal("-0.05")) - ) - - def test_negative_down_payment_raises(self) -> None: - with pytest.raises(ValueError, match="down_payment"): - score_listing_v2(**self._realistic_inputs(down_payment=Decimal("-1000"))) - - def test_negative_annual_debt_service_raises(self) -> None: - with pytest.raises(ValueError, match="annual_debt_service"): - score_listing_v2( - **self._realistic_inputs(annual_debt_service=Decimal("-1000")) - ) - - def test_grm_matches_expected(self) -> None: - """GRM should be purchase_price / annual_rent.""" - result = score_listing_v2(**self._realistic_inputs()) - expected_grm = Decimal("250000") / (Decimal("2500") * 12) - # Allow small rounding difference - assert abs(result["grm"] - expected_grm) < Decimal("0.0001") - - def test_cap_rate_matches_expected(self) -> None: - """cap_rate should be annual_noi / purchase_price.""" - result = score_listing_v2(**self._realistic_inputs()) - expected = Decimal("18000") / Decimal("250000") - assert abs(result["cap_rate"] - expected) < Decimal("0.0001") - - def test_coc_year1_matches_expected(self) -> None: - """coc_year1 should be (annual_noi - annual_debt_service) / down_payment.""" - result = score_listing_v2(**self._realistic_inputs()) - expected = (Decimal("18000") - Decimal("14400")) / Decimal("62500") - assert abs(result["coc_year1"] - expected) < Decimal("0.0001") diff --git a/tests_bdd/steps/test_property_analysis.py b/tests_bdd/steps/test_property_analysis.py index 38e78b4d..007afa31 100644 --- a/tests_bdd/steps/test_property_analysis.py +++ b/tests_bdd/steps/test_property_analysis.py @@ -13,12 +13,8 @@ from pytest_bdd import given, parsers, scenario, then, when from core.models import InvestmentAnalysis, OperatingExpense, Property, RentalIncome -from investor_app.finance.utils import ( - cap_rate, - dscr, - estimate_insurance, - noi, -) +from investor_app.finance.mortgage import estimate_insurance +from investor_app.finance.utils import cap_rate, dscr, noi # Acceptance criteria require NOI and cap rate checks with ±3% relative tolerance. REL_TOLERANCE = 0.03