|
32 | 32 | "mortgage_owner_status", |
33 | 33 | ] |
34 | 34 |
|
| 35 | +# Upper bound used to reject impossible QRF mortgage-deduction outliers before |
| 36 | +# converting formula-level deductions into structural mortgage inputs. This is |
| 37 | +# intentionally above recent mortgage rates, but low enough to prevent an |
| 38 | +# imputed current-law deductible from requiring billion-dollar gross interest. |
| 39 | +MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE = 0.15 |
| 40 | + |
35 | 41 |
|
36 | 42 | def impute_tax_unit_mortgage_balance_hints( |
37 | 43 | data: Dict[str, Dict[int, np.ndarray]], |
@@ -117,10 +123,14 @@ def convert_mortgage_interest_to_structural_inputs( |
117 | 123 |
|
118 | 124 | The conversion is intentionally conservative: |
119 | 125 | * current-law deductible mortgage interest is preserved exactly |
| 126 | + unless a QRF-imputed outlier exceeds a high-rate bound |
120 | 127 | * current-law total interest deduction is preserved exactly |
121 | 128 | * SCF-imputed first-lien and HELOC splits are preserved when available |
122 | 129 | * weak balance hints are lifted to a conservative lower bound implied by |
123 | 130 | the observed deductible mortgage interest |
| 131 | + * itemizer balances are capped at the current-law debt cap when needed so |
| 132 | + the conversion cannot create implausible gross interest to preserve a |
| 133 | + noisy formula-level imputation |
124 | 134 | * the origination year is heuristic, because the current public pipeline |
125 | 135 | does not carry a mortgage-vintage input |
126 | 136 |
|
@@ -156,12 +166,6 @@ def convert_mortgage_interest_to_structural_inputs( |
156 | 166 | hinted_total_balance = np.maximum(first_balance_hint + second_balance_hint, 0) |
157 | 167 | balance_floor = _interest_implied_balance_floor(tax_unit_deductible, tp) |
158 | 168 |
|
159 | | - total_interest_deduction = _get_tax_unit_interest_deduction_target( |
160 | | - data, |
161 | | - tp, |
162 | | - tax_unit_deductible, |
163 | | - ) |
164 | | - |
165 | 169 | fallback_person_share = _filer_share(data, tp, person_tax_unit_idx, n_tax_units) |
166 | 170 | person_share = _normalize_person_share( |
167 | 171 | person_deductible, |
@@ -216,6 +220,24 @@ def convert_mortgage_interest_to_structural_inputs( |
216 | 220 | total_balance = first_balance + second_balance |
217 | 221 |
|
218 | 222 | applicable_cap = np.where(origination_year <= 2017, pre_cap, post_cap) |
| 223 | + tax_unit_deductible = _cap_deductible_mortgage_interest( |
| 224 | + tax_unit_deductible, |
| 225 | + applicable_cap, |
| 226 | + ) |
| 227 | + first_balance, second_balance = _cap_itemizer_balances_to_current_law_cap( |
| 228 | + first_balance, |
| 229 | + second_balance, |
| 230 | + applicable_cap, |
| 231 | + has_mortgage, |
| 232 | + ) |
| 233 | + total_balance = first_balance + second_balance |
| 234 | + |
| 235 | + total_interest_deduction = _get_tax_unit_interest_deduction_target( |
| 236 | + data, |
| 237 | + tp, |
| 238 | + tax_unit_deductible, |
| 239 | + ) |
| 240 | + |
219 | 241 | deductible_share = np.ones(n_tax_units, dtype=np.float32) |
220 | 242 | capped_mask = has_mortgage & (total_balance > applicable_cap) |
221 | 243 | deductible_share[capped_mask] = ( |
@@ -294,6 +316,33 @@ def _get_tax_unit_interest_deduction_target( |
294 | 316 | return np.maximum(values, tax_unit_deductible).astype(np.float32) |
295 | 317 |
|
296 | 318 |
|
| 319 | +def _cap_deductible_mortgage_interest( |
| 320 | + deductible_mortgage_interest: np.ndarray, |
| 321 | + applicable_cap: np.ndarray, |
| 322 | +) -> np.ndarray: |
| 323 | + max_deductible = MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE * applicable_cap |
| 324 | + return np.minimum(deductible_mortgage_interest, max_deductible).astype(np.float32) |
| 325 | + |
| 326 | + |
| 327 | +def _cap_itemizer_balances_to_current_law_cap( |
| 328 | + first_balance: np.ndarray, |
| 329 | + second_balance: np.ndarray, |
| 330 | + applicable_cap: np.ndarray, |
| 331 | + has_mortgage: np.ndarray, |
| 332 | +) -> tuple[np.ndarray, np.ndarray]: |
| 333 | + total_balance = first_balance + second_balance |
| 334 | + needs_cap = has_mortgage & (total_balance > applicable_cap) |
| 335 | + if not np.any(needs_cap): |
| 336 | + return first_balance, second_balance |
| 337 | + |
| 338 | + scale = np.ones_like(total_balance, dtype=np.float32) |
| 339 | + scale[needs_cap] = applicable_cap[needs_cap] / total_balance[needs_cap] |
| 340 | + return ( |
| 341 | + (first_balance * scale).astype(np.float32), |
| 342 | + (second_balance * scale).astype(np.float32), |
| 343 | + ) |
| 344 | + |
| 345 | + |
297 | 346 | def _get_tax_unit_mortgage_balance_hints( |
298 | 347 | data: Dict[str, Dict[int, np.ndarray]], |
299 | 348 | time_period: int, |
|
0 commit comments