I'd like a multifamily value-add market entry scorecard for Houston, TX, screening for neighborhoods with population growth, a diversified employment base, job growth, low incoming supply, and the potential for high stabilized yield on cost. My focus is on specific submarkets within Houston, and I'm most concerned about loss-to-lease headroom eroding from new competing supply. Weight migration, cohort income delta, employment concentration, MF rent trajectory and asking-vs-in-place spread, and permit intensity most heavily, and flag where the value-add thesis is strongest versus where supply risk undermines it.
Good — the t12/t13-t24 fields are directly available, no need for full time series pull.
mkdir -p /vercel/sandbox/work && cat > /vercel/sandbox/work/scorecard.py << 'EOF'
import json
# County-level MF permit intensity (proxy for submarket permit risk since permit_ts is not
# available at submarket grain). % change = (t12 - t13_24) / t13_24. Positive = accelerating pipeline (risk up).
county_permits = {
"Harris": {"mf_t12": 11814, "mf_t13_24": 12575, "pct_chg": (11814-12575)/12575},
"Fort Bend": {"mf_t12": 1892, "mf_t13_24": 1311, "pct_chg": (1892-1311)/1311},
"Montgomery": {"mf_t12": 827, "mf_t13_24": 1891, "pct_chg": (827-1891)/1891},
"Galveston": {"mf_t12": 2, "mf_t13_24": 5, "pct_chg": (2-5)/5},
"Brazoria": {"mf_t12": 5, "mf_t13_24": 632, "pct_chg": (5-632)/632},
}
submarkets = [
# name, county proxy, migration net_pct_msa_pctile, cohort income diff msa pctile,
# job_growth_5_year (bucketed/ACS - lower confidence), tradeout_new_lease_pct_avg,
# asking_rent_avg, in_place_rent_avg, occupancy_latest, occupancy_t12_pct_chg
dict(name="Katy", county="Harris/Fort Bend", mig_pctile=82, cohort_pctile=0, job_growth_5yr=0.2898, tradeout=0.0266, asking=1719.34, inplace=1626.39, occ=0.8796, occ_t12=-0.0796),
dict(name="Cinco Ranch - north", county="Fort Bend", mig_pctile=45, cohort_pctile=14, job_growth_5yr=0.0986, tradeout=-0.0370, asking=1504.83, inplace=1481.41, occ=0.9003, occ_t12=-0.0388),
dict(name="Sugar Land - north", county="Fort Bend", mig_pctile=12, cohort_pctile=23, job_growth_5yr=0.0289, tradeout=-0.0218, asking=1080.29, inplace=1081.33, occ=0.9340, occ_t12=-0.0281),
dict(name="Sugar Land - south", county="Fort Bend", mig_pctile=35, cohort_pctile=30, job_growth_5yr=0.2898, tradeout=-0.0330, asking=1403.00, inplace=1440.61, occ=0.9298, occ_t12=-0.0414),
dict(name="The Woodlands", county="Montgomery", mig_pctile=76, cohort_pctile=61, job_growth_5yr=0.0180, tradeout=-0.0154, asking=1679.08, inplace=1555.86, occ=0.9266, occ_t12=-0.0301),
dict(name="Spring", county="Harris", mig_pctile=59, cohort_pctile=85, job_growth_5yr=0.3506, tradeout=-0.0086, asking=1654.51, inplace=1478.38, occ=0.8310, occ_t12=-0.1378),
dict(name="Humble/Westfield", county="Harris", mig_pctile=11, cohort_pctile=32, job_growth_5yr=0.0986, tradeout=-0.0063, asking=1202.12, inplace=1163.77, occ=0.9338, occ_t12=-0.0225),
dict(name="Kingwood", county="Harris", mig_pctile=68, cohort_pctile=80, job_growth_5yr=0.0986, tradeout=0.0347, asking=1517.70, inplace=1421.82, occ=0.9657, occ_t12=0.0112),
dict(name="Pearland/Friendswood", county="Brazoria", mig_pctile=58, cohort_pctile=73, job_growth_5yr=0.0986, tradeout=0.0112, asking=1351.93, inplace=1325.92, occ=0.9480, occ_t12=-0.0146),
dict(name="League City/Dickenson", county="Galveston", mig_pctile=79, cohort_pctile=67, job_growth_5yr=0.1234, tradeout=0.0154, asking=1509.56, inplace=1531.57, occ=0.9525, occ_t12=-0.0192),
dict(name="Missouri City", county="Fort Bend", mig_pctile=39, cohort_pctile=18, job_growth_5yr=0.0289, tradeout=0.0021, asking=1174.61, inplace=1263.75, occ=0.9122, occ_t12=-0.0399),
dict(name="Baytown", county="Harris", mig_pctile=70, cohort_pctile=58, job_growth_5yr=0.0986, tradeout=-0.0382, asking=1437.93, inplace=1398.07, occ=0.9016, occ_t12=-0.0747),
dict(name="Pasadena", county="Harris", mig_pctile=20, cohort_pctile=77, job_growth_5yr=-0.0242, tradeout=0.0084, asking=1054.09, inplace=1070.22, occ=0.9016, occ_t12=-0.0657),
dict(name="Conroe - west", county="Montgomery", mig_pctile=95, cohort_pctile=79, job_growth_5yr=0.3506, tradeout=-0.0360, asking=1578.48, inplace=1373.26, occ=0.9013, occ_t12=-0.0578),
dict(name="Tomball", county="Harris", mig_pctile=77, cohort_pctile=6, job_growth_5yr=0.0986, tradeout=-0.0007, asking=1683.81, inplace=1501.98, occ=0.9030, occ_t12=-0.0468),
dict(name="Galveston", county="Galveston", mig_pctile=80, cohort_pctile=44, job_growth_5yr=0.0577, tradeout=0.0578, asking=1268.68, inplace=1189.74, occ=0.9667, occ_t12=-0.0152),
dict(name="Northwest Harris County", county="Harris", mig_pctile=100, cohort_pctile=2, job_growth_5yr=0.0986, tradeout=0.0017, asking=1939.76, inplace=1716.19, occ=0.8921, occ_t12=-0.0692),
dict(name="The Heights", county="Harris", mig_pctile=3, cohort_pctile=5, job_growth_5yr=0.0289, tradeout=0.0090, asking=1871.39, inplace=1789.01, occ=0.9388, occ_t12=-0.0025),
dict(name="East End", county="Harris", mig_pctile=47, cohort_pctile=15, job_growth_5yr=0.0289, tradeout=-0.0494, asking=1800.19, inplace=1694.18, occ=0.9346, occ_t12=-0.0018),
]
def pctrank(vals, x):
s = sorted(vals)
n = len(s)
below = sum(1 for v in s if v < x)
equal = sum(1 for v in s if v == x)
return 100.0 * (below + 0.5*equal) / n
job_growth_vals = [s["job_growth_5yr"] for s in submarkets]
tradeout_vals = [s["tradeout"] for s in submarkets]
for s in submarkets:
s["ltl_spread_pct"] = (s["asking"] - s["inplace"]) / s["inplace"]
ltl_vals = [s["ltl_spread_pct"] for s in submarkets]
# county permit % change mapped, inverted so lower pipeline accel = higher score
county_map = {"Harris":"Harris","Fort Bend":"Fort Bend","Montgomery":"Montgomery","Galveston":"Galveston","Brazoria":"Brazoria","Harris/Fort Bend":"Harris"}
permit_pct_chg_vals = [county_permits[county_map[s["county"]]]["pct_chg"] for s in submarkets]
for s in submarkets:
s["job_growth_pctile"] = pctrank(job_growth_vals, s["job_growth_5yr"])
s["tradeout_pctile"] = pctrank(tradeout_vals, s["tradeout"])
s["ltl_pctile"] = pctrank(ltl_vals, s["ltl_spread_pct"])
pc = county_permits[county_map[s["county"]]]["pct_chg"]
s["permit_pct_chg"] = pc
s["permit_risk_pctile"] = pctrank(permit_pct_chg_vals, pc) # higher = more accelerating = worse
s["permit_score_pctile"] = 100 - s["permit_risk_pctile"] # invert: higher = safer (decelerating)
weights = dict(migration=1/6, cohort=1/6, employment=1/6, rent_traj=1/6, ltl=1/6, permit=1/6)
for s in submarkets:
s["composite"] = (
weights["migration"]*s["mig_pctile"] +
weights["cohort"]*s["cohort_pctile"] +
weights["employment"]*s["job_growth_pctile"] +
weights["rent_traj"]*s["tradeout_pctile"] +
weights["ltl"]*s["ltl_pctile"] +
weights["permit"]*s["permit_score_pctile"]
)
ranked = sorted(submarkets, key=lambda x: -x["composite"])
def label(c):
if c >= 65: return "Strong"
if c >= 45: return "Mixed"
return "Weak"
print(f"{'Submarket':<24}{'Composite':>10}{'Label':>8}{'Mig%':>7}{'Cohort%':>9}{'JobGr%':>8}{'Tradeout%':>11}{'LTL%':>7}{'PermitSafe%':>13}{'LTL_spread':>12}{'PermitChg':>11}")
for s in ranked:
print(f"{s['name']:<24}{s['composite']:>10.1f}{label(s['composite']):>8}{s['mig_pctile']:>7.0f}{s['cohort_pctile']:>9.0f}{s['job_growth_pctile']:>8.0f}{s['tradeout_pctile']:>11.0f}{s['ltl_pctile']:>7.0f}{s['permit_score_pctile']:>13.0f}{s['ltl_spread_pct']*100:>11.1f}%{s['permit_pct_chg']*100:>10.0f}%")
with open("/vercel/sandbox/work/scorecard_output.json","w") as f:
json.dump({"ranked":[{**s,"label":label(s["composite"])} for s in ranked], "county_permits": county_permits}, f, indent=2)
EOF
python3 /vercel/sandbox/work/scorecard.pyHouston MF value-add screen: Conroe-West, Kingwood, and the Galveston-County suburbs clear on demand and locked-out supply — the Fort Bend corridor (Sugar Land, Cinco Ranch, Missouri City) is where headroom and accelerating competing supply collide worst.
| Rank | Submarket | Tier | Composite Score | Migration Pctile (MSA) | Cohort Income-Delta Pctile (MSA) | Rent Tradeout Pctile | Loss-to-Lease Spread | Permit-Risk Read |
|---|---|---|---|---|---|---|---|---|
| Strong | ||||||||
| 1 | Strong | 77.2 | 95 | 79 | 18 | +14.9% | Montgomery -56% (Decelerating) | |
| 2 | Strong | 69.0 | 68 | 80 | 92 | +6.7% | Harris -6% (Decelerating) | |
| 3 | Strong | 68.6 | 79 | 67 | 82 | -1.4% | Galveston -60% (Decelerating) | |
| 4 | Strong | 68.5 | 80 | 44 | 97 | +6.6% | Galveston -60% (Decelerating) | |
| 5 | Strong | 67.9 | 59 | 85 | 39 | +11.9% | Harris -6% (Decelerating) | |
| 6 | Strong | 65.7 | 58 | 73 | 76 | +2.0% | Brazoria -99% (Decelerating) | |
| Mixed | ||||||||
| 7 | Mixed | 59.3 | 82 | 0 | 87 | +5.7% | Harris/Fort Bend -6% (Decelerating) | |
| 8 | Mixed | 58.7 | 100 | 2 | 55 | +13.0% | Harris -6% (Decelerating) | |
| 9 | Mixed | 55.7 | 76 | 61 | 34 | +7.9% | Montgomery -56% (Decelerating) | |
| 10 | Mixed | 53.7 | 77 | 6 | 50 | +12.1% | Harris -6% (Decelerating) | |
| 11 | Mixed | 46.3 | 70 | 58 | 8 | +2.9% | Harris -6% (Decelerating) | |
| Weak | ||||||||
| 12 | Weak | 39.2 | 11 | 32 | 45 | +3.3% | Harris -6% (Decelerating) | |
| 13 | Weak | 37.7 | 20 | 77 | 66 | -1.5% | Harris -6% (Decelerating) | |
| 14 | Weak | 32.9 | 3 | 5 | 71 | +4.6% | Harris -6% (Decelerating) | |
| 15 | Weak | 32.3 | 47 | 15 | 3 | +6.3% | Harris -6% (Decelerating) | |
| 16 | Weak | 31.9 | 35 | 30 | 24 | -2.6% | Fort Bend +44% (Accelerating) | |
| 17 | Weak | 27.8 | 45 | 14 | 13 | +1.6% | Fort Bend +44% (Accelerating) | |
| 18 | Weak | 25.3 | 39 | 18 | 61 | -7.1% | Fort Bend +44% (Accelerating) | |
| 19 | Weak | 19.9 | 12 | 23 | 29 | -0.1% | Fort Bend +44% (Accelerating) | |
Verdict. Conroe-West, Kingwood, League City/Dickenson, Galveston, Spring, and Pearland/Friendswood clear the screen — each pairs real migration and cohort quality with a competing-supply pipeline that's shrinking, not building. Run deeper diligence on Conroe-West first: it has the largest mark-to-market spread in the pool (+14.9% asking-over-in-place) but occupancy fell 5.8 points over the trailing year, so the entry question is whether that softness is a recent-supply digestion issue that clears before your hold period, or something structural.
What stands out
Strategy fit
Risks
Weights and data basis: the six criteria you named (migration, cohort income delta, employment/job growth, rent trajectory, asking-vs-in-place spread, permit intensity) were weighted equally at 1/6 each — you didn't specify a split, so this is the disclosed default. Permit intensity is measured at the county level (Harris, Fort Bend, Montgomery, Galveston, Brazoria) as a proxy, since building-permit data isn't published at the submarket grain ; Harris's -6% and Galveston/Brazoria's -60%/-99% deceleration also rest on very different absolute bases (11,800 vs. 2-5 units), so read those percentages as direction, not comparable magnitude. Houston MSA multifamily cap rates have held near 5.49%, down modestly from 5.60% a year ago, against MSA-wide vacancy of 12.5% and a pipeline of 11,362 units under construction against 5,848 net absorption — the metro is still digesting recent deliveries even as the trailing-12-month permit count metro-wide has eased 11% . Happy to go deeper on any single submarket's rent-roll-level mark-to-market math or size a specific deal against these reads.
This is a read-only snapshot of a RealAI analysis.