Skip to content

Fix housing market accounting combined main#93

Open
eravigne wants to merge 11 commits into
mainfrom
fix-housing-market-accounting-combined-main
Open

Fix housing market accounting combined main#93
eravigne wants to merge 11 commits into
mainfrom
fix-housing-market-accounting-combined-main

Conversation

@eravigne

Copy link
Copy Markdown
Contributor

Summary

This PR fixes several housing-market accounting and clearing issues that could leave households or properties in inconsistent states after market clearing.

The main goal is to make housing transactions internally consistent: households that buy or rent should end up inhabiting the corresponding property, sold properties should not also be rented in the same clearing, occupied homes should not be sold out from under households that did not move, and housing-market IDs should be preserved during initialization.

What changed

  • Correct private renter participation in housing demand.
    Private renters use tenure status 3, but the demand code was checking the wrong renter status. This could prevent renters from entering the housing-market demand calculation.

  • Fix sale listings for households hoping to move.
    The moving-household mask is now converted to household IDs before comparing with property owner IDs. This avoids treating boolean mask positions as owner IDs.

  • Include -1 vacancies in rental listings.
    The rental market now treats both NaN and -1 inhabitant IDs as vacant homes.

  • Use completed housing sales for mortgage demand.
    Household mortgage demand is based on housing sales, not unrelated transaction rows.

  • Record observed housing ratios after clearing.
    Price/value and rent/value ratios are now updated after transactions have been applied, so they reflect the post-clearing market state.

  • Preserve housing-market IDs during initialization.
    Existing household/property IDs are kept, while only missing IDs are normalized to -1.

  • Prevent sold homes from also being rented.
    Sales clear before rentals, and sold properties are removed from the rental pool before rental matching.

  • Handle empty automatic housing matches.
    The automatic clearer now returns an empty transaction table when there is no demand or no available property, instead of trying to optimize an empty cost matrix.

  • Prevent infeasible occupied sales.
    A sale cannot complete if it would overwrite an inhabitant who has not completed a move in the same clearing.

  • Preserve occupants during move chains/swaps.
    When clearing a buyer’s previous residence, the code only clears it if that buyer is still the recorded inhabitant. This avoids deleting a new occupant in swap-like transaction chains.

Tests

Added regression coverage for:

  • renter tenure status 3 generating housing demand;
  • moving-household sale listing using owner IDs correctly;
  • -1 vacant homes being listed for rent;
  • mortgage demand using completed home sales;
  • housing ratios being recorded after clearing;
  • housing-market initialization preserving IDs;
  • sold properties not also being rented;
  • automatic clearing with empty demand/supply;
  • occupied sales not displacing stationary inhabitants;
  • swap/move chains not clearing the wrong occupant.

tests\test_macromodel\unit\test_agents\test_households\func\test_property_probability.py tests\test_macromodel\unit\test_agents\test_households\func\test_property_performance.py tests\test_macromodel\unit\test_country\test_country.py tests\test_macromodel\unit\test_markets\test_housing_market\test_housing_market.py

@eravigne eravigne requested a review from jose-moran May 19, 2026 16:43

@jose-moran jose-moran left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, there's only a couple of changes I had in mind (mostly for readability) , and I also know it may not be possible to avoid the dataframe copy (but I think it's worth trying)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should do something for clarity, eg introduce

RENTING_INDEX = 3

(or with an Enum)

at the top of the file and then just have

`ind_renting = np.array(household_residence_tenure_status == RENTING_INDEX)```

that way this would not happen again.

inhabitant_ids = housing_data["Corresponding Inhabitant Household ID"]
owner_wants_to_move = owner_ids.isin(household_ids_hoping_to_move)
# Owners can only list homes they can vacate: vacant or owner-occupied.
property_is_vacant = inhabitant_ids.isna() | inhabitant_ids.eq(-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, I don't even remember the difference between nan index and -1 (I'm guessing this is what is being tested here). I would introduce aliases for this like in the prev comment

now_up_for_rent = np.where(np.isnan(housing_data["Corresponding Inhabitant Household ID"].values))[0]
newly_up_for_rent = [ind for ind in now_up_for_rent if ind not in prev_up_for_rent]
# Rental availability is property-index based; NaN and -1 both mean vacancy.
prev_up_for_rent = np.flatnonzero(housing_data["Up for Rent"].eq(True).values)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mixing parts where you write array == value and array.eq(value) is there a reason? Otherwise I'd prefer consistency

break
feasible_sales = feasible_sales.loc[keep_transaction].copy()

return feasible_sales.reset_index(drop=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this fn but I wonder if it's necessary to copy and return a whole dataframe. This can be quite costly computationally. Do you think you can do a workaround where you only return the indices you care about, rather than forcing a copy of the whole df? Otherwise this seems a bit greedy to me

self.states["current_sales"] = self._filter_feasible_current_sales(
household_received_mortgages=household_received_mortgages,
household_financial_wealth=household_financial_wealth,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah so here you could maybe just keep track of the index, I'm not sure if possible though

# Mix of renters (0) and people in social housing (-1)
household_residence_tenure_status = np.array([-1, 0, 0, 0, -1, 0, 0, -1, 0, 0])
# Mix of renters (3) and people in social housing (-1)
household_residence_tenure_status = np.array([-1, 3, 3, 3, -1, 3, 3, -1, 3, 3])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment on index naming

@agurgone agurgone self-requested a review May 21, 2026 22:26

@agurgone agurgone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Households.compute_target_credit() should filter to sales internally

The PR fixes the immediate caller by making Country.prepare_credit_market_clearing() pass only "Sell" rows. That is good, but Households.compute_target_credit() still treats every received row as a house purchase.

So if a future caller passes the full current_sales table, "Rental" rows could again create mortgage demand.

Suggested behavior:

if "sales_types" in current_sales.columns:
    current_sales = current_sales.loc[current_sales["sales_types"] == "Sell"]

A direct household-level regression would be useful: pass one "Rental" row and one "Sell" row, and check that only the sale buyer gets mortgage demand.

2. The mortgage-stock mismatch is still open

This PR fixes the housing-market loading bug, but it does not fix the separate mismatch between initialized household mortgage debt and the credit-market mortgage book.

The diagnostic showed:

household mortgage_debt[t=0] ~= 825.8B
credit-market mortgage principal[t=0] ~= 534.0B

The likely cause is that household mortgage debt is initialized from:

HMR mortgages + mortgages on other properties

while the credit-market mortgage loan book appears to be built only from:

HMR mortgages

So after the first credit-market step, household mortgage_debt is recomputed from a smaller credit-market book. That is separate from the housing loader fix and should remain a follow-up issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants