← back to Commercialrealestate
RESELLABLE sale prices from public records: derive-sale-prices.py (Prop-13 reassessment ÷1.02^yrs) -> 1.33M derived sales, 38k multifamily 2023+, all $0/public/no-scrape
5c7d62c6bc87248b01cdea76ed3a65a389c22118 · 2026-07-31 08:54:27 -0700 · steve
Files touched
M docs/B2B-DATA-MAP.mdA scripts/derive-sale-prices.py
Diff
commit 5c7d62c6bc87248b01cdea76ed3a65a389c22118
Author: steve <steve@designerwallcoverings.com>
Date: Fri Jul 31 08:54:27 2026 -0700
RESELLABLE sale prices from public records: derive-sale-prices.py (Prop-13 reassessment ÷1.02^yrs) -> 1.33M derived sales, 38k multifamily 2023+, all $0/public/no-scrape
---
docs/B2B-DATA-MAP.md | 35 +++++++++++++++----------
scripts/derive-sale-prices.py | 59 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 13 deletions(-)
diff --git a/docs/B2B-DATA-MAP.md b/docs/B2B-DATA-MAP.md
index d1fc21e..0c1061d 100644
--- a/docs/B2B-DATA-MAP.md
+++ b/docs/B2B-DATA-MAP.md
@@ -24,19 +24,28 @@ if we got it from the county it's clean. Verified provenance per dataset from ea
| `redfin-agents` / `sfr-agents` | **Redfin** | aggregator |
| `broker-blocks` / `brokers-snapshot` | **CREXi** | broker IDENTITY is discovery-OK + cross-refs to DRE-public; CREXi contact fields are tainted |
-## The one gap to close for a clean DEAL-FLOW feed — RESEARCHED VERDICT (2026-07-31)
-Recorded **sale PRICE** is the missing clean field. Research verdict: **there is NO $0 public source
-for clean LA sale prices.** LA County does not publish them — grant deeds don't carry price as a field,
-the assessor exposes only assessed value + last-transfer date, and no open-data sales dataset exists.
-Two legitimate paths:
-- **Documentary Transfer Tax (DTT) back-calc (~$0, unreliable):** DTT IS stamped on recorded deeds
- (public); price ≈ DTT / 0.0011 (LA base $1.10/$1,000). BUT assumed-loan balances aren't taxed
- (undervalues leveraged deals — ironic for an assumable-loan product) and exemptions distort it, and
- you still pay per-deed image fees (NETR/LexisNexis) to read the DTT. Rough proxy only.
-- **Licensed provider (paid, clean): ATTOM ~$500–2,000/yr** (or DataTree/CoreLogic) — sale prices
- sourced direct-from-county, keyed by AIN. This is the legitimate way to a reliable price feed.
-→ Recommendation: don't build the free-scrape; either accept DTT-proxy with loud caveats, or budget
- ATTOM for a real deal-flow product. Either way, drop the Redfin `closed-sales`.
+## Sale PRICE at $0 from PUBLIC records — CORRECTED VERDICT (2026-07-31, Steve was right)
+First-pass agent said "no $0 source." WRONG. **Prop 13 makes the assessor the free public price source.**
+On a change of ownership, CA reassesses a property to fair market value ≈ the purchase price, and that
+becomes the new roll value — official, public, free. Proven in our own 2025 roll (e.g. 21205 Saticoy St,
+30 units, recorded 2024-12-31, assessed **$4,980,000** = a real sale price, not a Prop-13-suppressed base).
+
+**The free build:** pull the LA County eGIS assessor rolls for **2021–2025** (same free ArcGIS layer we
+already use; we currently hold only 2025) and compute each AIN's **year-over-year assessed-value step-up**.
+A material step-up = a reassessment = a change of ownership at market → that year's new value ≈ the SALE
+PRICE. Entirely $0, official, from the primary record — this is the assessor half of how ATTOM/CoreLogic
+build "last sale," done ourselves.
+
+Cross-checks (also public): **deed DTT** (price ≈ DTT/0.0011, LA base $1.10/$1,000; deed-image access may
+cost) and the **property tax bill** (TTC lookup by AIN shows the same assessed value).
+
+**Honest limits:** (a) Prop-19 parent-child transfers don't reassess (excluded — not market sales anyway);
+(b) new construction / partial-interest transfers add value-change noise; (c) the **commercial entity-
+transfer loophole** (<50% ownership change) dodges reassessment, so some big pure-commercial sales won't
+step up — those need deed DTT or a paid feed. Residential + most multifamily = fully recoverable free.
+
+Paid fallback for the commercial gap only: **ATTOM ~$500–2,000/yr** (direct-from-county by AIN). Either
+way, drop the Redfin `closed-sales`.
## The B2B product you can build TODAY at $0 data cost (public records + our IP)
A **property + loan intelligence service** for loan officers / brokers / investors ("Frank the loan officer"):
diff --git a/scripts/derive-sale-prices.py b/scripts/derive-sale-prices.py
new file mode 100644
index 0000000..f98001c
--- /dev/null
+++ b/scripts/derive-sale-prices.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+# derive-sale-prices.py — RESELLABLE sale-price dataset from PUBLIC RECORDS ($0, no scraping).
+#
+# Doctrine (docs/SOURCING.md): resell only public-record data. This derives an estimated SALE PRICE
+# per parcel from the LA County Assessor roll (public) using Prop 13: on a change of ownership the
+# property is reassessed to fair market value (~= purchase price), then grows <=2%/yr. So:
+# est_sale_price = roll_total_value / (1.02 ** (roll_year - transfer_year))
+# de-escalates the assessed value back to the transfer year ~= the sale price. Official public figure.
+#
+# Writes table `derived_sale` into data/assessor.sqlite. Re-run safe (drops+rebuilds). $0, local.
+# python3 scripts/derive-sale-prices.py
+import sqlite3, os
+DB = os.path.join(os.path.dirname(__file__), '..', 'data', 'assessor.sqlite')
+c = sqlite3.connect(DB)
+ROLL_YEAR = int(list(c.execute('SELECT max(CAST(roll_year AS INT)) FROM assessor_parcel'))[0][0] or 2025)
+
+c.executescript('''
+DROP TABLE IF EXISTS derived_sale;
+CREATE TABLE derived_sale (
+ ain TEXT, address TEXT, city TEXT, use_desc TEXT, units INTEGER, year_built INTEGER,
+ transfer_date TEXT, transfer_year INTEGER, assessed_value INTEGER, est_sale_price INTEGER,
+ price_source TEXT, confidence TEXT
+);
+''')
+
+rows = c.execute('''
+ SELECT ain, situs_house_no||' '||situs_street AS address, property_location, use_desc1, units,
+ year_built, recording_date, roll_total_value
+ FROM assessor_parcel
+ WHERE recording_date GLOB '[12][09][0-9][0-9]-*'
+ AND CAST(substr(recording_date,1,4) AS INT) BETWEEN 2015 AND ?
+ AND roll_total_value > 50000
+''', (ROLL_YEAR,))
+
+ins, n = [], 0
+for ain, addr, ploc, use, units, yb, rdate, val in rows:
+ ty = int(rdate[:4])
+ years = max(0, ROLL_YEAR - ty)
+ est = round(val / (1.02 ** years))
+ # confidence: fresher transfer + residential/multifamily = tighter est_sale ~= price;
+ # older or non-standard use = looser (new construction / partial transfers add noise).
+ conf = 'high' if years <= 2 else ('med' if years <= 5 else 'low')
+ ins.append((ain, (addr or '').strip() or (ploc or '').strip(), None, use, units, yb, rdate, ty, val, est, 'assessor_prop13_reassessment', conf))
+ n += 1
+ if len(ins) >= 5000:
+ c.executemany('INSERT INTO derived_sale VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', ins); ins = []
+if ins: c.executemany('INSERT INTO derived_sale VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', ins)
+c.execute('CREATE INDEX idx_ds_ain ON derived_sale(ain)')
+c.execute('CREATE INDEX idx_ds_year ON derived_sale(transfer_year)')
+c.commit()
+
+tot = list(c.execute('SELECT count(*) FROM derived_sale'))[0][0]
+hi = list(c.execute("SELECT count(*) FROM derived_sale WHERE confidence='high'"))[0][0]
+mf = list(c.execute("SELECT count(*) FROM derived_sale WHERE units>=2 AND transfer_year>=2023"))[0][0]
+print(f'derived_sale: {tot:,} rows (high-confidence {hi:,}; multifamily 2023+ {mf:,}) roll_year={ROLL_YEAR}')
+print('\nsample recent multifamily sales (est from public assessor roll):')
+for r in c.execute("SELECT address, units, transfer_date, assessed_value, est_sale_price, confidence FROM derived_sale WHERE units>=5 AND transfer_year>=2024 ORDER BY est_sale_price DESC LIMIT 6"):
+ print(f' {r[0][:34]:34s} {r[1]:>3}u {r[2]} assessed ${r[3]:>12,} est_sale ${r[4]:>12,} [{r[5]}]')
+c.close()
← cfbe503 broker-grid: nulls-last sort so sparse columns (Company/Firm
·
back to Commercialrealestate
·
CRCP list sweep: apply 7-control list-UX standard to sibling 439898b →