Skip to content

Repository files navigation

Elko County Rural Land Market Analysis

The Land of 40 Acres - Analyzed by a Wall Street Analyst

Python 3.13 uv Tests Data vintage Notebook runs offline License: MIT

Elko County rural land prices since 1980, nominal and real, with NBER recessions shaded

This repository dissects a market almost nobody models: 40 to 79 acre remote rural parcels in Elko County, Nevada, read directly from the county assessor's transaction tape. It asks the question every land-flipping pitch dodges: where does the operator margin actually come from? The answer has teeth. Operators exit at the same price private sellers get; their entire edge comes from buying 49% below the private market. There is no retail premium to capture, because retail is just the open market. "Buy wholesale, sell retail" is not a strategy here. Sourcing is the product, and the tape proves it.

It is also built like research rather than content. Every figure is computed by one notebook from the redacted data snapshots shipped alongside it, behind 346 unit tests, a verification gate, and seeded bootstrap intervals, so the numbers are checkable rather than takeable on faith. And the dataset appreciates: the county delinquency roll reports current state only, so a paid-off account vanishes from the feed forever. Each refresh captures a vintage that can never be pulled again, which over time turns the distress screen from a static filter into a signal that can be validated against outcomes, on data nobody can buy retroactively.

Contents: The headline result · Key findings · Quick start · Where the data comes from · Repository layout · Refreshing the data · Reporting on a different period · Portal mechanics · Caveats · Roadmap

The headline result

The operator margin in this market is earned entirely on acquisition.

Confidence intervals for the three price ratios: retail vs private spans 1.00, both wholesale ratios sit near 0.5

Operators selling to individuals clear a $17,495 median, statistically indistinguishable from the $19,499 that individuals get selling to each other (p = 0.50, Cliff's delta -0.08, negligible). Individuals who sell to an operator take $10,000, a 49% discount to the private-market price (p < 0.001, delta -0.47). The exit price is simply the open market, available to any buyer. The edge is sourcing, and capturing it means replicating the acquisition operation: the motivated-seller pipeline is the product, not a feature of it.

The discount survives controls. Fitting a pricing model on parcel attributes alone, operator acquisitions clear 33% below fitted fair value while private sales of comparable parcels clear 15% above, a conditional discount of 42% (p = 0.0001, Cliff's delta -0.48) against 49% unconditional. Holding size, year and location fixed barely dents it.

Key findings

Full derivations, robustness cuts and interactive charts are in elko_40ac_analysis.ipynb; section 13.6 collects the headline numbers in one table.

Market structure

  • Volume is thin but tradeable. About 80 qualified arm's-length trades a year across all vacant parcels of 40 acres and up; 170 trades over 36 months inside the analysis frame. Enough for segment medians, short of what a hedonic model would need.
  • Turnover is about 3.3% a year, so the constraint is deal flow rather than pricing. 240 of the 2,402 rural parcels with sale history recorded a qualified sale in the 36-month window. The useful question is which of the 2,402 will be in next year's 80.
  • The strategy is an operating business with a hard ceiling. The entire qualified market moves roughly $1.24 million a year and the wholesale channel where the edge lives runs about 14 trades a year. $100,000 of net income needs 12 deals, or 86% of every wholesale trade in the county, against four incumbents who together acquire about 10 parcels a year. That arithmetic is why the incumbents are private companies rather than funds.
  • The 40-acre band contains two distinct submarkets. Spring Creek and near-town parcels trade around $4,400 per acre against roughly $375 for remote rural sections. They are analysed separately throughout.

Pricing and the assessor's roll

  • Land trades around $375 an acre, with wholesale at $250 against $455 for private sales. A semiannual index puts drift at about 16% a year, but its interval runs to zero and the latest half sits 24% below the 2025H2 peak, so drift is not distinguishable from flat.
  • The roll is reliable at the market level and unreliable per parcel. The median parcel sells for 2.49x its taxable value (95% CI 2.09 to 2.78), so the roll carries rural land near 40% of market. But the coefficient of dispersion is 62.8 against an IAAO vacant-land standard of 20, with clear regressivity (PRD 1.23). Screen with it; never price an individual parcel with it.
  • Realized round trips are profitable on a thin sample. 17 matched buy-sell pairs returned a median 2.00x over a 265-day hold, 77% annualized as the median of pair-level returns. No pair lost money, but the sample is survivorship-selected: only parcels that resold inside the window appear.
  • The returns came from the entry, not the market. Splitting each round trip into the market move over the holding period and the entry discount, the entry explains essentially all of it.
  • Carry is negligible and the selling cost is not. Property tax runs about 0.5% of price paid a year, derived from the roll rather than assumed. Net of that and a 6% selling cost, the median round trip is 68% annualized against 77% gross.

Geography

Median dollars per acre by survey block across the county grid

  • Neither location nor parcel size explains price within the band. Activity concentrates in an eastern cluster (Ranges 66-70E) and a central one (56-59E), with the west effectively dead. The raw east-west price gap dissolves under controls (level effect -2%, p = 0.88): composition, not a location premium. Acreage elasticity is indistinguishable from proportional (1.16, CI 0.32 to 2.00).

History

  • The price history contains a 65% real decline that has never been recovered. From the 1983 peak to a 1995 trough in constant dollars, with 68% of the years since 1980 spent more than 20% below a prior high and the current level 18% below the all-time high. Qualified volume fell 88% from its 2006 peak to a 2014 trough and has never recovered; recent full years run about half the peak.
  • Current prices sit in the upper middle of that history. The real per-acre level is at the 74th percentile of the 47 observed years, and land sits at the 67th percentile of its range against the county's own house price index.
  • The margin does not need the market. Holding entry prices fixed and repricing exits 30% below today still leaves the median round trip at 25% annualized, which is the case for underwriting to the historical median rather than to the current level.

Ownership

  • Ownership is dispersed to the point of irrelevance. The 2,402 rural parcels are held by 1,508 ownership interests after names sharing a mailbox are merged. The largest private interest holds 1.6% of private parcels, 89% of interests hold exactly one, and the private HHI is 17. Government and administrative bodies hold 507 parcels, a fifth of the register, and are excluded from every concentration figure.
  • The largest operators are accumulating. Entity buyers form a long tail of 44 distinct names, 38 appearing exactly once. The four repeat operators bought 29 parcels and sold 4; three of them have sold nothing at all.
  • Mailing addresses expose entity linkage that names conceal. 18 addresses carry more than one grantee name, and 14 link genuinely different names to a single address. GOTMA LAND HOLDINGS and PREMIUM LAND COMPANY share one Springfield mailbox; CLAIM THE ACRE and HONEYRIDGE PROPERTIES share one in Fargo. Measured concentration is therefore a floor.
  • Seven private interests hold ten or more parcels, 149 between them: the only counterparties where a portfolio-scale entry could be negotiated in one conversation. The county treasurer holds 34 tax-deed parcels, 31 delinquent, which is the distress pipeline in its final state.

The distress screen

  • Section 12 screens the 2,402-parcel population against the county delinquency roll and returns 415 parcels in five tiers: 6 delinquent three or more years, 114 delinquent more recently, and 295 held 25 years or longer by out-of-state individuals current on tax.
  • The screen is tiered rather than scored, deliberately. The tax roll reports current delinquency on the current owner, so for a parcel that sold in 2024 the arrears belong to the buyer, not to the motivated seller the screen exists to find. It cannot be backtested from a single snapshot, and weights that cannot be estimated are not asserted. Validation is forward, which is what the vintage archive is for.

Quick start

uv is the only prerequisite for reproducing the published notebook. You do not need Python installed first: uv reads .python-version and downloads Python 3.13 itself if the machine does not have it.

1. Clone the repository and step into it:

git clone https://github.com/lenamonj/elko-data.git
cd elko-data

2. Install uv, skipping this step if uv --version already answers. macOS and Linux:

curl -LsSf https://astral.sh/uv/install.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Package managers work too: brew install uv on macOS, winget install --id=astral-sh.uv -e on Windows, or pipx install uv anywhere. Open a new terminal afterwards so uv is on your PATH.

3. Create the environment, from inside the repo:

uv sync

This resolves uv.lock exactly, so you get the same package versions the analysis was run on: Python 3.13 with pandas 3.0.5, plotly 6.9 and statsmodels 0.14.6. Everything, Jupyter included, installs inside the project's .venv; nothing touches the machine globally, and every later command runs through uv run, so there is never an environment to activate and no Scripts\ versus bin/ path to get wrong. No kernel registration is required either: when Jupyter runs from inside the environment, its default python3 kernel is this environment. The next step just gives it a friendlier name.

4. Name the kernel (optional, recommended). The environment works immediately under generic labels, but one command registers it with Jupyter as Python 3.13 (elko-data) so it is unmistakable in every kernel picker:

uv run python -m ipykernel install --user --name elko-data --display-name "Python 3.13 (elko-data)"

This also makes the environment visible to any Jupyter installed outside the project. It records an absolute path, so re-run it if you move or re-clone the repo.

5. Run the notebook. Cell 1 prints the interpreter path, so a mis-bound kernel announces itself immediately. Pick whichever front end you use:

  • VS Code. Open the folder, open elko_40ac_analysis.ipynb, and in the kernel picker choose Python 3.13 (elko-data), or the detected .venv environment if you skipped step 4. The repo ships a .vscode/settings.json that activates the environment in new terminals and runs notebooks from the repo root, so relative paths like data/ just resolve; the interpreter path is deliberately not hard-coded there, because auto-discovery handles the platform differences.

  • JupyterLab. uv run jupyter lab, then pick Python 3.13 (elko-data), or the default Python 3 kernel if you skipped step 4. Launched this way, either choice is the project environment.

  • Headless. Needs no kernel name at all; this pins execution to the environment that runs it:

    uv run jupyter nbconvert --to notebook --execute --inplace --ExecutePreprocessor.kernel_name=python3 elko_40ac_analysis.ipynb

Bootstrap confidence intervals are seeded, so repeated runs reproduce exactly.

6. Optional: a FRED API key, only for refreshing the data. The notebook reads the committed snapshots in data/fred/, so reproducing it needs no key and no network. A key is required only to pull fresh data with pull_fred.py or refresh.py. Keys are free: request one at fredaccount.stlouisfed.org/apikeys, then put it in a .env file at the repo root:

FRED_API_KEY=your-key-here

.env is gitignored, so the key never enters the repository. The county assessor pulls need no credentials at all.

Where the data comes from

All public record, pulled from the Elko County Assessor records search (elko-search.gsacorp.io) through its advanced sales search and native JSON export. No paid feeds. The committed sales snapshot covers sale dates 2023-08-01 to 2026-08-01, vacant records of 40+ acres: 848 raw records, 74 fields each.

Fields that matter most:

  • qu_cd (Qualified / Unqualified) is the assessor's own arm's-length determination, produced for Nevada Department of Taxation ratio studies. This is the primary comp filter and removes over half the raw records, standing in for the deed-type and related-party logic that would otherwise have to be rebuilt from recorder documents.
  • Transaction Code gives deed nature. Government (GE), trustee (TRD), non-valid (NVS) and non-sale (NSY) records are excluded.
  • Taxable Value (Sale Year) is the assessor's contemporaneous estimate, the denominator of every ratio in the analysis.
  • Use Code at Sale 600 is NRS 361A agricultural land, valued at use value rather than market, and is excluded from all ratio work.

Filtering funnel: 848 raw, 396 county-qualified, 360 after deed-type exclusions, 328 above nominal consideration, 255 after dropping multi-parcel instruments, 193 in the 40-79 acre band, 170 after restricting to remote rural and the vacant single-family-residential use code.

Privacy. The repository ships the exact inputs behind the published figures, compressed to about a megabyte, so running the notebook from a clone reproduces every number without touching the county portal. Those committed snapshots carry one redaction: each mailing street is replaced by a token. Every consumer of that field tests equality only, so mailbox grouping and interest consolidation are unchanged, which the test suite asserts directly. data/raw/ holds the pulls exactly as the county returns them, with real mailing addresses, plus the screening list and the ownership register built from them. None of it is committed. distress_screen_<date>.csv in particular is a list of people under financial pressure, and it stays local.

Repository layout

README.md                        this file
elko_40ac_analysis.ipynb         the analysis, executed with saved outputs
elko_client.py                   shared search-and-export client for the county portal
fred_client.py                   FRED API client, sanitized errors, cached snapshots
pull_fred.py                     16 validated FRED series into data/fred/
valuation.py                     valuation and relative value primitives, unit tested
verify_outputs.py                repository gate, run before every commit
refresh.py                       pull, screen, execute, verify in one command
pull_sales.py                    assessor export puller, rolling or explicit window
pull_universe.py                 every recorded sale of a 40-79 acre use-120 parcel
pull_tax.py                      countywide delinquent tax accounts
distress.py                      screening rules, pure functions, unit tested
ownership.py                     register, interests, concentration, book vintage
screen.py                        builds the distress list from the two pulls
tests/test_distress.py           screening rule tests
tests/test_valuation.py          valuation primitive tests
tests/test_ownership.py          register and consolidation tests
tests/test_fred.py               FRED client tests, no network
tests/test_snapshot.py           redaction and reproducibility tests
snapshot.py                      snapshot selection, gzip reading, redaction
publish_snapshot.py              raw pulls to the committed, redacted snapshot set
docs/                            figures exported from the executed notebook
data/<stem>_<date>.json.gz       the inputs behind every published figure, committed
data/fred/                       dated FRED snapshots, committed
data/raw/                        pulls with real mailing addresses, never committed
pyproject.toml / uv.lock         dependency manifest and lock (the reproducibility artifacts)

Refreshing the data, and why refreshes compound

One command pulls everything, rebuilds the screening list, executes the notebook, and runs the gate:

uv run python refresh.py

Pulls land in data/raw/; publish_snapshot.py redacts and compresses them into data/, which is what the notebook reads and what the repository commits, so the published snapshot and the published notebook always move together. To rebuild from what is already on disk, with no network:

uv run python refresh.py --skip-pulls

The individual pulls, if you want them separately:

uv run python pull_sales.py --months 36
uv run python pull_universe.py data/raw/universe_sales_40_79_$(date +%Y%m%d).json
uv run python pull_tax.py data/raw/tax_delinquent_$(date +%Y%m%d).json
uv run python pull_fred.py
uv run python publish_snapshot.py
uv run python screen.py

Every refresh is an addition, not a replacement. Pulls are date-stamped, nothing prunes old stamps, and the notebook's ASOF mechanism reads any vintage on disk, so each run adds a layer to a point-in-time archive instead of overwriting the last one.

That matters most for the delinquency roll, because it is the one source that cannot be recovered after the fact. FRED retransmits full history on every pull, and recorded sales stay re-pullable indefinitely. The tax roll reports current delinquency on the current owner only: the moment an account is paid, it leaves the feed without a trace. A delinquency snapshot can only be taken while it is true. Skip six months and those states are gone from every source, at any price.

This is what makes the screen in section 12 compound in value. A single snapshot can rank parcels by pressure but cannot say what that pressure predicts, which is why the screen is tiered rather than scored. A stack of snapshots can: which tiers cure, which roll deeper, and which precede a sale all become observable, and the screen graduates from a filter into a signal that can be validated against outcomes. Nevada collects property tax in quarterly installments (due the third Monday of August and the first Mondays of October, January and March), so refreshes timed shortly after those dates catch the roll at maximum turnover. Anyone who starts pulling today is building a dataset that cannot be bought later, and each vintage costs one uv run python refresh.py.

Reporting on a different period

The configuration cell at the top of the notebook is the only place to edit:

ASOF = None            # report date; None means the latest data on disk
WINDOW = None          # ("YYYY-MM-DD", "YYYY-MM-DD") to zoom; None means trailing months
WINDOW_MONTHS = 36     # length of the default trailing window
LONG_TAPE_START = 1980 # analytical floor for the price history

WINDOW zooms the transaction analytics in sections 4 through 10. History and external context always run full length to ASOF, because their job is to place the window in proportion. The distress screen always uses the newest tax snapshot at or before ASOF: it is current-state data and cannot be rewound past the snapshots on disk.

Sections that a short window cannot support say so and skip rather than reporting a median of three sales. A twelve-month window executes cleanly and drops the block ranking; two years retains everything.

pull_fred.py writes a date-stamped snapshot of 16 series to data/fred/. The notebook reads only that snapshot, so it runs with no network once the pull is done, and every external figure carries the observation date of the series behind it.

pull_sales.py arguments are start date, end date, vacant flag (V, I, or any), and output path. The other two pullers take an output path; date-stamp it, because the tax roll moves and a screen you acted on in August should still be reconstructable in November. screen.py defaults to the most recent date-stamped pair in data/.

Figures quoted in this file come from the 2026-08-02 pulls. After a refresh, the notebook is the current record and section 13.6 collects the headline numbers in one table.

Portal mechanics

The site holds search state server-side per session: GET / for a token, POST the search form, then POST the matching export endpoint. Sales and parcels export from /export/adv/s and /export/adv/p; the tax search exports from /export/adv/t, not /export/tax, which returns HTML. Two quirks are worth knowing. query[parcel][use][] is silently ignored on parcel search but honoured on sale search, so a use-code filter there buys nothing and warns you of nothing. And the tax export emits byte-identical duplicate rows for some Personal Property accounts, so exports reconcile against the search count on distinct account, not on row count.

The pullers keep a polite request cadence. Leave it that way; this is a public service, not an API with an SLA.

Caveats

  • Declared values tie to Nevada transfer tax, so understatement has a motive. Treat them as a floor. The wholesale leg is the least verifiable precisely because cash purchases leave no deed of trust to cross-check.
  • Financed retail prices embed a terms premium. Separating cash-equivalent value needs recorder deed-of-trust data that is not integrated, so the retail tape here is nominal.
  • Repeat sales are survivorship-selected; held inventory contributes nothing.
  • 2026 is partial, through August 1. Thin quarterly cells are suppressed throughout.
  • Assessor improvement codes such as WELL HOOKUP ONLY are administrative, not proof of a physical asset.

Roadmap

In priority order: treasurer delinquency roll, which the buy-side finding makes the single highest-value addition, then GIS parcel adjacency, then recorder financing forensics, then listings capture.

License

MIT. This is market research on public records. It is not an appraisal and not investment advice.

About

Transaction-tape analysis of the Elko County, Nevada rural land market

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages