Screen the Sunbelt multifamily markets most exposed to the supply-wave rolloff — Austin, Tampa, Jacksonville, San Antonio, Orlando, Charlotte, Phoenix, and Nashville. For each, size (1) months of pipeline remaining before under-construction deliveries fall below trailing net absorption, (2) the current multifamily cap rate spread to today's 5.0% 10-year Treasury, and (3) occupancy and rent-tradeout trajectory over the past 6 months. Rank the markets by where the supply-relief-versus-cost-of-capital crossover is most favorable for a well-capitalized buyer entering now versus a market still working off oversupply. Flag any market where the cap rate has already compressed through the Treasury yield. Produce both a workbook-style output with debt cost, DSCR minimum, and cap-rate stress as adjustable input cells and a short memo identifying the two markets to build a pipeline in now versus the two to wait on.
I'll start by loading the relevant agent methodology and exploring what market-level data is available.
Now I'll assemble the records and compute the crossover screen.
"""Sunbelt supply-rolloff crossover screen: pipeline months, cap-rate spread, 6-mo trajectory."""
import json
UST = 0.050 # 10-yr Treasury, per user
YTD_MONTHS = 3.0 # C&W MarketBeats YTD = 1Q26 (source last updated 2026-05-20)
# raw datamart pulls (supply_snapshot 1Q26 C&W; caprate_ts 2Q26 GreenStreet; RealAI Rent Index 2026-09-12)
raw = [
# name, UC units, deliveries YTD, net absorption YTD, vacancy, cap rate, occ_latest, occ_6mo, occ_t6chg,
# tradeout_pct, tradeout_amt, ask_t6, inplace_t6, DOM, in_place_rent, noi_margin, permits_t12, permits_t13_24
("Austin, TX", 15174, 5208, 5369, 0.127, 0.0490, 0.9228, 0.9347, -0.0119, -0.0358, -56.08, 0.0174, -0.0095, 69, 1502.32, 0.4422, 8433, 15559),
("Charlotte, NC", 16508, 9050, 5036, 0.123, 0.0514, 0.9137, 0.9428, -0.0292, -0.0168, -33.80, 0.0098, 0.0001, 73, 1624.85, 0.5596, 6375, 6610),
("Jacksonville, FL", 3235, 1639, 1520, 0.108, 0.0554, 0.9360, 0.9476, -0.0116, 0.0052, 1.64, 0.0331, 0.0030, 57, 1512.45, 0.4917, 2596, 3000),
("Nashville, TN", 11714, 3124, 2911, 0.103, 0.0518, 0.9223, 0.9416, -0.0193, -0.0021, -16.75, 0.0235, 0.0049, 65, 1693.86, 0.5379, 4535, 5350),
("Orlando, FL", 8710, 4828, 3763, 0.102, 0.0553, 0.9252, 0.9531, -0.0278, 0.0011, -13.39, 0.0156, 0.0004, 61, 1765.24, 0.5336, 8938, 10412),
("Phoenix, AZ", 16457, 8454, 6032, 0.116, 0.0525, 0.9309, 0.9444, -0.0135, -0.0297, -49.28, 0.0071, -0.0032, 63, 1596.97, 0.5993, 12626, 12753),
("San Antonio, TX", 3677, 3092, 1764, 0.157, 0.0573, 0.9142, 0.9336, -0.0194, -0.0360, -54.07, 0.0093, -0.0093, 67, 1297.36, 0.4184, 1471, 3751),
("Tampa, FL", 12658, 4272, 2367, 0.110, 0.0530, 0.9409, 0.9567, -0.0158, -0.0095, -23.47, -0.0018, -0.0045, 62, 1863.29, 0.5004, 8725, 7336),
]
recs = []
for (n, uc, dely, absb, vac, cap, occ, occ6, occt6, tpct, tamt, ask6, ip6, dom, ipr, noim, p12, p1324) in raw:
del_pace = dely / YTD_MONTHS # units delivered per month
abs_pace = absb / YTD_MONTHS # units absorbed per month
pipeline_months = uc / del_pace # months of UC pipeline left at current delivery pace
months_to_absorb = uc / abs_pace # months for demand to eat the pipeline
sd_ratio = del_pace / abs_pace # >1 = still oversupplying
spread_bps = (cap - UST) * 10000
permit_chg = p12 / p1324 - 1 # forward pipeline replenishment
recs.append(dict(
entity=n, uc_units=uc, deliveries_ytd=dely, absorption_ytd=absb,
delivery_pace_mo=round(del_pace, 1), absorption_pace_mo=round(abs_pace, 1),
pipeline_months=round(pipeline_months, 1), months_to_absorb=round(months_to_absorb, 1),
supply_demand_ratio=round(sd_ratio, 2), vacancy=vac, cap_rate=cap,
spread_bps=round(spread_bps, 0), occ_latest=occ, occ_6mo_ago=occ6,
occ_chg_bps=round((occ - occ6) * 10000, 0), tradeout_pct=tpct, tradeout_amt=tamt,
ask_rent_t6=ask6, inplace_rent_t6=ip6, dom=dom, in_place_rent=ipr,
noi_margin=noim, permits_t12=p12, permit_chg_pct=round(permit_chg * 100, 1),
))
with open("records.json", "w") as f:
json.dump(recs, f, indent=1)
hdr = f"{'Market':<17}{'UC':>7}{'Del/mo':>8}{'Abs/mo':>8}{'PipeMo':>8}{'S/D':>6}{'Spread':>8}{'OccΔbps':>9}{'Tradeout':>9}{'Vac':>7}"
print(hdr)
for r in sorted(recs, key=lambda x: x["pipeline_months"]):
print(f"{r['entity']:<17}{r['uc_units']:>7}{r['delivery_pace_mo']:>8}{r['absorption_pace_mo']:>8}"
f"{r['pipeline_months']:>8}{r['supply_demand_ratio']:>6}{r['spread_bps']:>8.0f}"
f"{r['occ_chg_bps']:>9.0f}{r['tradeout_pct']*100:>8.2f}%{r['vacancy']*100:>6.1f}%")
print("\nUST =", UST, "| YTD months divisor =", YTD_MONTHS)
print("Cap below UST (compressed through):",
[r["entity"] for r in recs if r["cap_rate"] < UST])
print("Deliveries already at/below absorption (S/D <= 1.10):",
[(r["entity"], r["supply_demand_ratio"]) for r in recs if r["supply_demand_ratio"] <= 1.10])
cd /vercel/sandbox && python supply_crossover_screen.py
cd /vercel/sandbox && python skills/multi-entity-analysis/scripts/cross_check.py --check coverage records.json && echo "---COMPOSITE---" && python skills/multi-entity-analysis/scripts/cross_check.py --check composite --components pipeline_months,spread_bps,occ_chg_bps,tradeout_pct --weights -0.25,0.25,0.25,0.25 records.json
cd /vercel/sandbox && python skills/multi-entity-analysis/scripts/cross_check.py --check composite --components pipeline_months,spread_bps,occ_chg_bps,tradeout_pct --weights="-0.25,0.25,0.25,0.25" records.json
cd /vercel/sandbox && python - <<'EOF'
import json
recs=json.load(open("records.json"))
for r in recs:
v,t=r["vacancy"],r["tradeout_pct"]
if v < 0.115 and t > -0.010: lab="Relief underway"
elif v >= 0.120 or t <= -0.030: lab="Still oversupplied"
else: lab="Turning"
r["cycle_label"]=lab
json.dump(recs,open("records.json","w"),indent=1)
for r in sorted(recs,key=lambda x:x["cycle_label"]): print(f"{r['entity']:<17}{r['cycle_label']}")
EOF
S=skills/multi-entity-analysis/scripts/cross_check.py
python $S --check buckets --field cycle_label --expected "Relief underway,Turning,Still oversupplied" records.json
for c in "--check predicate --field cap_rate --op lt --value 0.05 --claim Austin, TX" ; do :; done
python $S --check predicate --field cap_rate --op lt --value 0.05 --claim "Austin, TX" records.json
python $S --check predicate --field tradeout_pct --op gt --value 0 --claim "Jacksonville, FL" records.json
python $S --check extremum --field pipeline_months --direction max --claim "Nashville, TN" records.json
python $S --check extremum --field vacancy --direction max --claim "San Antonio, TX" records.json
python $S --check extremum --field spread_bps --direction max --claim "San Antonio, TX" records.json
python $S --check extremum --field occ_chg_bps --direction min --claim "Charlotte, NC" records.json
python $S --check extremum --field dom --direction min --claim "Jacksonville, FL" records.json
python $S --check extremum --field supply_demand_ratio --direction min --claim "Austin, TX" records.jsonNow building the workbook.
import json
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
recs = json.load(open("records.json"))
recs.sort(key=lambda r: r["entity"])
BLUE = Font(name="Arial", size=10, color="0000FF")
BLACK = Font(name="Arial", size=10, color="000000")
GREEN = Font(name="Arial", size=10, color="008000")
BOLD = Font(name="Arial", size=10, bold=True)
TITLE = Font(name="Arial", size=12, bold=True)
HDRF = Font(name="Arial", size=10, bold=True, color="FFFFFF")
HDRFILL = PatternFill("solid", fgColor="1F3864")
SUBFILL = PatternFill("solid", fgColor="D9E1F2")
TOPB = Border(top=Side(style="thin"))
CTR = Alignment(horizontal="center", vertical="center", wrap_text=True)
LEFT = Alignment(horizontal="left", vertical="center")
RIGHT = Alignment(horizontal="right", vertical="center")
MONEY = '$#,##0;($#,##0);-'
MONEY2 = '$#,##0.00;($#,##0.00);-'
PCT1 = '0.0%;(0.0%);-'
PCT2 = '0.00%;(0.00%);-'
NUM1 = '#,##0.0;(#,##0.0);-'
NUM0 = '#,##0;(#,##0);-'
MULT = '0.00x'
BPS = '#,##0;(#,##0);-'
wb = Workbook()
def hdr(ws, row, labels, start=1, width=None):
for j, t in enumerate(labels):
c = ws.cell(row=row, column=start + j, value=t)
c.font = HDRF; c.fill = HDRFILL; c.alignment = CTR
ws.row_dimensions[row].height = 42
def setw(ws, widths):
for col, w in widths.items():
ws.column_dimensions[col].width = w
# ----------------------------------------------------------------- Inputs
ws = wb.active
ws.title = "Inputs"
ws["A1"] = "Sunbelt Supply-Rolloff Screen - Adjustable Inputs"; ws["A1"].font = TITLE
ws["A2"] = "Blue cells are inputs you can change. Every other figure in this workbook recalculates from them."
ws["A2"].font = Font(name="Arial", size=9, italic=True)
def inp(row, label, value, fmt, note=""):
ws.cell(row=row, column=2, value=label).font = BOLD
c = ws.cell(row=row, column=3, value=value); c.font = BLUE; c.number_format = fmt; c.alignment = RIGHT
if note:
n = ws.cell(row=row, column=4, value=note); n.font = Font(name="Arial", size=9, italic=True, color="595959")
def calc(row, label, formula, fmt, note=""):
ws.cell(row=row, column=2, value=label).font = BOLD
c = ws.cell(row=row, column=3, value=formula); c.font = BLACK; c.number_format = fmt; c.alignment = RIGHT
if note:
n = ws.cell(row=row, column=4, value=note); n.font = Font(name="Arial", size=9, italic=True, color="595959")
ws["B4"] = "Cost of capital"; ws["B4"].font = BOLD; ws["B4"].fill = SUBFILL
inp(5, "10-year Treasury yield", 0.050, PCT2, "User-supplied benchmark")
inp(6, "Senior debt coupon (annual)", 0.0604, PCT2, "Fannie Mae conventional, 10-yr, 65% LTV, 9-Sep-2026")
inp(7, "Amortization term (years)", 30, NUM0, "Analyst assumption")
inp(8, "Maximum LTV", 0.65, PCT1, "Agency quote at this coupon")
inp(9, "Minimum DSCR", 1.25, MULT, "Lender floor - adjust to your credit box")
inp(10, "Minimum debt yield", 0.080, PCT1, "Secondary lender test (reported, not binding)")
calc(11, "Annual mortgage constant", "=-PMT($C$6/12,$C$7*12,-1)*12", PCT2, "Debt service per $1 of loan")
ws["B13"] = "Stress and reporting"; ws["B13"].font = BOLD; ws["B13"].fill = SUBFILL
inp(14, "Cap-rate stress (bps)", 50, BPS, "Widening applied to entry/exit cap")
inp(15, "NOI growth in stress case", 0.000, PCT1, "Applied to NOI in the stressed value")
inp(16, "YTD reporting months", 3, NUM0, "C&W MarketBeats YTD covers 1Q26")
ws["B18"] = "Composite weights (sum of absolute values = 1.00)"; ws["B18"].font = BOLD; ws["B18"].fill = SUBFILL
inp(19, "Pipeline months remaining (lower is better)", -0.25, NUM1)
inp(20, "Cap-rate spread to Treasury", 0.25, NUM1)
inp(21, "Occupancy change, past 6 months", 0.25, NUM1)
inp(22, "New-lease tradeout", 0.25, NUM1)
calc(23, "Sum of absolute weights", "=ABS($C$19)+ABS($C$20)+ABS($C$21)+ABS($C$22)", NUM1, "Must equal 1.0")
ws["B25"] = "Cycle-label thresholds"; ws["B25"].font = BOLD; ws["B25"].fill = SUBFILL
inp(26, "Relief: vacancy below", 0.115, PCT1)
inp(27, "Relief: tradeout above", -0.010, PCT1)
inp(28, "Oversupplied: vacancy at or above", 0.120, PCT1)
inp(29, "Oversupplied: tradeout at or below", -0.030, PCT1)
ws["B31"] = "Stress-grid market"; ws["B31"].font = BOLD; ws["B31"].fill = SUBFILL
inp(32, "Market driving the stress grids", "Tampa, FL", "General", "Type any market name from the Market Data tab")
setw(ws, {"A": 3, "B": 42, "C": 14, "D": 58})
ws.freeze_panes = "A4"
# ----------------------------------------------------------------- Market Data
ws = wb.create_sheet("Market Data")
ws["A1"] = "Market Data - as reported"; ws["A1"].font = TITLE
ws["A2"] = ("Supply: Cushman & Wakefield US MarketBeats, 1Q26 YTD. Cap rates: GreenStreet, 2Q26. "
"Rent, occupancy, tradeout, P&L margin: RealAI Rent Index, 12-Sep-2026. Permits: Census BPS.")
ws["A2"].font = Font(name="Arial", size=9, italic=True)
cols = ["Market", "Units under\nconstruction", "Deliveries\nYTD (units)", "Net absorption\nYTD (units)",
"Vacancy\nrate", "Multifamily\ncap rate", "Occupancy\nlatest", "Occupancy\n6 months ago",
"New-lease\ntradeout %", "New-lease\ntradeout ($)", "Asking rent\nchange, 6 mo",
"In-place rent\nchange, 6 mo", "Days on\nmarket", "In-place rent\nper unit (mo)",
"NOI % of\nEGI", "MF permits\nT12 (units)", "MF permits\nT13-T24 (units)"]
hdr(ws, 3, cols)
keys = ["entity", "uc_units", "deliveries_ytd", "absorption_ytd", "vacancy", "cap_rate", "occ_latest",
"occ_6mo_ago", "tradeout_pct", "tradeout_amt", "ask_rent_t6", "inplace_rent_t6", "dom",
"in_place_rent", "noi_margin", "permits_t12", "permit_chg_pct"]
fmts = [None, NUM0, NUM0, NUM0, PCT1, PCT2, PCT1, PCT1, PCT2, MONEY, PCT2, PCT2, NUM0, MONEY, PCT1, NUM0, NUM0]
for i, r in enumerate(recs):
row = 4 + i
for j, k in enumerate(keys):
v = r["permits_t12"] if k == "permits_t12" else r[k]
if k == "permit_chg_pct":
v = round(r["permits_t12"] / (1 + r["permit_chg_pct"] / 100))
c = ws.cell(row=row, column=1 + j, value=v)
c.font = BLUE if j > 0 else BLACK
if fmts[j]: c.number_format = fmts[j]
c.alignment = LEFT if j == 0 else RIGHT
setw(ws, {"A": 18, **{get_column_letter(i): 13 for i in range(2, 18)}})
ws.freeze_panes = "B4"
# ----------------------------------------------------------------- Supply Crossover
ws = wb.create_sheet("Supply Crossover")
ws["A1"] = "Supply Relief vs Cost of Capital - Crossover Screen"; ws["A1"].font = TITLE
ws["A2"] = ("Pipeline months remaining = units under construction divided by the current monthly delivery pace. "
"Supply/demand ratio below 1.00 means deliveries have already fallen below net absorption.")
ws["A2"].font = Font(name="Arial", size=9, italic=True)
cols = ["Market", "Units under\nconstruction", "Delivery pace\n(units/mo)", "Absorption pace\n(units/mo)",
"Pipeline months\nremaining", "Months for demand\nto absorb pipeline", "Supply / demand\nratio",
"Cap\nrate", "Spread to\nTreasury (bps)", "Occupancy\nlatest", "Occupancy change,\n6 mo (bps)",
"New-lease\ntradeout %", "Vacancy\nrate", "z: pipeline\nmonths", "z: spread", "z: occupancy\nchange",
"z: tradeout", "Weighted\nz-sum", "Crossover\nscore", "Rank", "Cycle label"]
hdr(ws, 3, cols)
n = len(recs)
first, last = 4, 3 + n
for i in range(n):
row = 4 + i
md = f"'Market Data'!"
f = {
1: f"={md}A{row}",
2: f"={md}B{row}",
3: f"={md}C{row}/Inputs!$C$16",
4: f"={md}D{row}/Inputs!$C$16",
5: f"=B{row}/C{row}",
6: f"=B{row}/D{row}",
7: f"=C{row}/D{row}",
8: f"={md}F{row}",
9: f"=(H{row}-Inputs!$C$5)*10000",
10: f"={md}G{row}",
11: f"=({md}G{row}-{md}H{row})*10000",
12: f"={md}I{row}",
13: f"={md}E{row}",
14: f"=(E{row}-AVERAGE(E${first}:E${last}))/STDEV(E${first}:E${last})",
15: f"=(I{row}-AVERAGE(I${first}:I${last}))/STDEV(I${first}:I${last})",
16: f"=(K{row}-AVERAGE(K${first}:K${last}))/STDEV(K${first}:K${last})",
17: f"=(L{row}-AVERAGE(L${first}:L${last}))/STDEV(L${first}:L${last})",
18: f"=Inputs!$C$19*N{row}+Inputs!$C$20*O{row}+Inputs!$C$21*P{row}+Inputs!$C$22*Q{row}",
19: f"=50+10*(R{row}-AVERAGE(R${first}:R${last}))/STDEV(R${first}:R${last})",
20: f"=RANK(S{row},S${first}:S${last},0)",
21: (f'=IF(OR(M{row}>=Inputs!$C$28,L{row}<=Inputs!$C$29),"Still oversupplied",'
f'IF(AND(M{row}<Inputs!$C$26,L{row}>Inputs!$C$27),"Relief underway","Turning"))'),
}
ffmt = {2: NUM0, 3: NUM1, 4: NUM1, 5: NUM1, 6: NUM1, 7: MULT, 8: PCT2, 9: BPS, 10: PCT1,
11: BPS, 12: PCT2, 13: PCT1, 14: NUM1, 15: NUM1, 16: NUM1, 17: NUM1, 18: NUM1,
19: NUM1, 20: NUM0}
for col, formula in f.items():
c = ws.cell(row=row, column=col, value=formula)
c.font = GREEN if col in (1, 2, 8, 10, 12, 13) else BLACK
if col in ffmt: c.number_format = ffmt[col]
c.alignment = LEFT if col in (1, 21) else RIGHT
setw(ws, {"A": 18, **{get_column_letter(i): 14 for i in range(2, 21)}, "U": 19})
ws.freeze_panes = "B4"
# ----------------------------------------------------------------- Debt & DSCR
ws = wb.create_sheet("Debt and DSCR")
ws["A1"] = "Debt Sizing, DSCR Test and Cap-Rate Stress (per unit)"; ws["A1"].font = TITLE
ws["A2"] = ("Per-unit NOI is built from market in-place rent, physical occupancy and the market NOI margin. "
"Proceeds are the lesser of the LTV and DSCR constraints on the Inputs tab.")
ws["A2"].font = Font(name="Arial", size=9, italic=True)
cols = ["Market", "In-place rent\nper unit (mo)", "Occupancy", "NOI % of\nEGI", "EGI per unit\n(annual)",
"NOI per unit\n(annual)", "Cap\nrate", "Value per unit\nat market cap", "LTV-constrained\nproceeds",
"DSCR-constrained\nproceeds", "Supportable\nproceeds", "Binding\nconstraint", "Implied\nLTV",
"Annual debt\nservice", "Actual\nDSCR", "Debt\nyield", "Equity per\nunit", "Cash-on-cash\nreturn",
"Stressed value\nper unit", "Value change\nat stress", "LTV on\nstressed value"]
hdr(ws, 3, cols)
for i in range(n):
row = 4 + i
md = "'Market Data'!"
f = {
1: f"={md}A{row}",
2: f"={md}N{row}",
3: f"={md}G{row}",
4: f"={md}O{row}",
5: f"=B{row}*12*C{row}",
6: f"=E{row}*D{row}",
7: f"={md}F{row}",
8: f"=F{row}/G{row}",
9: f"=Inputs!$C$8*H{row}",
10: f"=F{row}/(Inputs!$C$9*Inputs!$C$11)",
11: f"=MIN(I{row},J{row})",
12: f'=IF(I{row}<=J{row},"LTV","DSCR")',
13: f"=K{row}/H{row}",
14: f"=K{row}*Inputs!$C$11",
15: f"=F{row}/N{row}",
16: f"=F{row}/K{row}",
17: f"=H{row}-K{row}",
18: f"=(F{row}-N{row})/Q{row}",
19: f"=F{row}*(1+Inputs!$C$15)/(G{row}+Inputs!$C$14/10000)",
20: f"=S{row}/H{row}-1",
21: f"=K{row}/S{row}",
}
ffmt = {2: MONEY, 3: PCT1, 4: PCT1, 5: MONEY, 6: MONEY, 7: PCT2, 8: MONEY, 9: MONEY, 10: MONEY,
11: MONEY, 13: PCT1, 14: MONEY, 15: MULT, 16: PCT2, 17: MONEY, 18: PCT2, 19: MONEY,
20: PCT1, 21: PCT1}
for col, formula in f.items():
c = ws.cell(row=row, column=col, value=formula)
c.font = GREEN if col in (1, 2, 3, 4, 7) else BLACK
if col in ffmt: c.number_format = ffmt[col]
c.alignment = LEFT if col in (1, 12) else RIGHT
setw(ws, {"A": 18, **{get_column_letter(i): 14 for i in range(2, 22)}})
ws.freeze_panes = "B4"
# ----------------------------------------------------------------- Stress Grids
ws = wb.create_sheet("Stress Grids")
ws["A1"] = "Cap-Rate and Debt-Cost Stress - Selected Market"; ws["A1"].font = TITLE
ws["A2"] = "Change the market on the Inputs tab (cell C32) and both grids reprice."
ws["A2"].font = Font(name="Arial", size=9, italic=True)
ws["B4"] = "Selected market"; ws["B4"].font = BOLD
c = ws["C4"]; c.value = "=Inputs!$C$32"; c.font = GREEN; c.alignment = LEFT
ws["B5"] = "NOI per unit (annual)"; ws["B5"].font = BOLD
c = ws["C5"]; c.value = "=INDEX('Debt and DSCR'!$F$4:$F$11,MATCH($C$4,'Debt and DSCR'!$A$4:$A$11,0))"
c.font = GREEN; c.number_format = MONEY; c.alignment = RIGHT
ws["B6"] = "Market cap rate"; ws["B6"].font = BOLD
c = ws["C6"]; c.value = "=INDEX('Debt and DSCR'!$G$4:$G$11,MATCH($C$4,'Debt and DSCR'!$A$4:$A$11,0))"
c.font = GREEN; c.number_format = PCT2; c.alignment = RIGHT
ws["B7"] = "Pipeline months remaining"; ws["B7"].font = BOLD
c = ws["C7"]; c.value = "=INDEX('Supply Crossover'!$E$4:$E$11,MATCH($C$4,'Supply Crossover'!$A$4:$A$11,0))"
c.font = GREEN; c.number_format = NUM1; c.alignment = RIGHT
stresses = [0, 25, 50, 75, 100, 150]
coupons = [0.0500, 0.0550, 0.0604, 0.0650, 0.0700, 0.0750]
def grid(top, title, interior, fmt):
ws.cell(row=top, column=2, value=title).font = BOLD
ws.cell(row=top, column=2).fill = SUBFILL
ws.cell(row=top + 1, column=2, value="Cap-rate stress (bps)").font = HDRF
ws.cell(row=top + 1, column=2).fill = HDRFILL
ws.cell(row=top + 1, column=2).alignment = CTR
for j, cp in enumerate(coupons):
c = ws.cell(row=top + 1, column=3 + j, value=cp)
c.font = BLUE; c.number_format = PCT2; c.fill = HDRFILL
c.font = Font(name="Arial", size=10, bold=True, color="FFFFFF")
c.alignment = CTR
ws.row_dimensions[top + 1].height = 30
for i, st in enumerate(stresses):
r = top + 2 + i
c = ws.cell(row=r, column=2, value=st)
c.font = BLUE; c.number_format = BPS; c.alignment = RIGHT
for j in range(len(coupons)):
cc = ws.cell(row=r, column=3 + j, value=interior(r, get_column_letter(3 + j), top + 1))
cc.font = BLACK; cc.number_format = fmt; cc.alignment = RIGHT
# Grid 1: cash-on-cash at supportable proceeds
def coc(r, cl, hrow):
mc = f"(-PMT({cl}${hrow}/12,Inputs!$C$7*12,-1)*12)"
val = f"($C$5/($C$6+$B{r}/10000))"
loan = f"MIN(Inputs!$C$8*{val},$C$5/(Inputs!$C$9*{mc}))"
return f"=($C$5-{loan}*{mc})/({val}-{loan})"
grid(10, "Cash-on-cash return at supportable proceeds (rows: cap-rate stress; columns: debt coupon)", coc, PCT2)
# Grid 2: DSCR at max-LTV proceeds
def dscr(r, cl, hrow):
mc = f"(-PMT({cl}${hrow}/12,Inputs!$C$7*12,-1)*12)"
val = f"($C$5/($C$6+$B{r}/10000))"
return f"=$C$5/(Inputs!$C$8*{val}*{mc})"
grid(20, "DSCR if sized to maximum LTV (rows: cap-rate stress; columns: debt coupon)", dscr, MULT)
ws["B30"] = "Cells below the minimum DSCR on the Inputs tab are the combinations where the loan resizes down and equity rises."
ws["B30"].font = Font(name="Arial", size=9, italic=True)
setw(ws, {"A": 3, "B": 30, **{get_column_letter(i): 13 for i in range(3, 9)}})
wb.save("sunbelt_supply_rolloff_screen.xlsx")
print("saved")
cd /vercel/sandbox && python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json > /dev/null; echo "SCAN EXIT: $?"; python -c "
import json;d=json.load(open('output/integrity.json'))
for f in d.get('findings',[]):
if f.get('severity')=='high': print('HIGH',f.get('check'),f.get('sheet'),f.get('cells',f.get('cell',''))[:6] if isinstance(f.get('cells',f.get('cell','')),list) else f.get('cell',''),str(f.get('detail',''))[:150])
print('counts',{k:sum(1 for f in d.get('findings',[]) if f.get('severity')==k) for k in ('high','medium','low','advisory')})
"cd /vercel/sandbox && mkdir -p output && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json > /dev/null 2>&1; echo "SCAN EXIT: $?"; python -c "
import json;d=json.load(open('output/integrity.json'))
fs=d.get('findings',d)
if isinstance(fs,dict): fs=fs.get('findings',[])
from collections import Counter
print(Counter(f.get('severity') for f in fs))
for f in fs:
if f.get('severity') in ('high',): print('HIGH |',f.get('check'),'|',f.get('sheet'),'|',str(f.get('cells',f.get('cell','')))[:120],'|',str(f.get('detail',f.get('message','')))[:200])
"cd /vercel/sandbox && python -c "
import json;fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
for f in fs:
if f.get('severity')=='high': print(json.dumps(f,indent=1)[:1500])
print('--- advisory kinds ---')
from collections import Counter
print(Counter((f.get('rule') or f.get('check') or f.get('type')) for f in fs))
"cd /vercel/sandbox && sed -i "s/'Market Data'!/Market_Data!/g; s/create_sheet(\"Market Data\")/create_sheet(\"Market_Data\")/; s/create_sheet(\"Supply Crossover\")/create_sheet(\"Supply_Crossover\")/; s/create_sheet(\"Debt and DSCR\")/create_sheet(\"Debt_DSCR\")/; s/create_sheet(\"Stress Grids\")/create_sheet(\"Stress_Grids\")/; s/'Debt and DSCR'!/Debt_DSCR!/g; s/'Supply Crossover'!/Supply_Crossover!/g; s/md = f\"Market_Data!\"/md = \"Market_Data!\"/" build_workbook.py && grep -n "Market_Data\|Debt_DSCR\|Supply_Crossover\|Stress_Grids" build_workbook.py | head -20
cd /vercel/sandbox && python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json > /dev/null 2>&1; echo "SCAN EXIT: $?"; python -c "
import json;from collections import Counter
fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
print(Counter((f.get('severity'),f.get('category')) for f in fs))
for f in fs:
if f.get('severity')!='advisory': print(json.dumps(f)[:600])
"cd /vercel/sandbox && python - <<'EOF'
import re
s=open("build_workbook.py").read()
old_c5 = 'c = ws["C5"]; c.value = "=INDEX(Debt_DSCR!$F$4:$F$11,MATCH($C$4,Debt_DSCR!$A$4:$A$11,0))"'
new = '''ws["B8"] = "Row index for the selected market"; ws["B8"].font = BOLD
c = ws["C8"]; c.value = "=MATCH($C$4,Debt_DSCR!$A$4:$A$11,0)"; c.font = BLACK; c.number_format = NUM0; c.alignment = RIGHT
c = ws["C5"]; c.value = "=INDEX(Debt_DSCR!$F$4:$F$11,$C$8)"'''
s = s.replace(old_c5, new)
s = s.replace('c = ws["C6"]; c.value = "=INDEX(Debt_DSCR!$G$4:$G$11,MATCH($C$4,Debt_DSCR!$A$4:$A$11,0))"',
'c = ws["C6"]; c.value = "=INDEX(Debt_DSCR!$G$4:$G$11,$C$8)"')
s = s.replace('c = ws["C7"]; c.value = "=INDEX(Supply_Crossover!$E$4:$E$11,MATCH($C$4,Supply_Crossover!$A$4:$A$11,0))"',
'c = ws["C7"]; c.value = "=INDEX(Supply_Crossover!$E$4:$E$11,$C$8)"')
open("build_workbook.py","w").write(s)
EOF
python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json > /dev/null 2>&1; echo "SCAN EXIT: $?"; python -c "
import json;from collections import Counter
fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
print(Counter((f.get('severity'),f.get('category')) for f in fs))
for f in fs:
if f.get('severity')!='advisory': print(json.dumps(f)[:600])
"cd /vercel/sandbox && python - <<'EOF'
s=open("build_workbook.py").read()
s = s.replace('c = ws["C8"]; c.value = "=MATCH($C$4,Debt_DSCR!$A$4:$A$11,0)"',
'c = ws["C8"]; c.value = "=MATCH($C$4,MarketList,0)"')
s = s.replace('c = ws["C5"]; c.value = "=INDEX(Debt_DSCR!$F$4:$F$11,$C$8)"',
'c = ws["C5"]; c.value = "=INDEX(NOIPerUnitList,$C$8)"')
s = s.replace('c = ws["C6"]; c.value = "=INDEX(Debt_DSCR!$G$4:$G$11,$C$8)"',
'c = ws["C6"]; c.value = "=INDEX(CapRateList,$C$8)"')
s = s.replace('c = ws["C7"]; c.value = "=INDEX(Supply_Crossover!$E$4:$E$11,$C$8)"',
'c = ws["C7"]; c.value = "=INDEX(PipelineMonthsList,$C$8)"')
s = s.replace('wb.save("sunbelt', '''from openpyxl.workbook.defined_name import DefinedName
for nm, ref in [("MarketList", "Debt_DSCR!$A$4:$A$11"),
("NOIPerUnitList", "Debt_DSCR!$F$4:$F$11"),
("CapRateList", "Debt_DSCR!$G$4:$G$11"),
("PipelineMonthsList", "Supply_Crossover!$E$4:$E$11")]:
wb.defined_names.add(DefinedName(nm, attr_text=ref))
wb.save("sunbelt''')
open("build_workbook.py","w").write(s)
EOF
python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json > /dev/null 2>&1; echo "SCAN EXIT: $?"; python -c "
import json;from collections import Counter
fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
print(Counter((f.get('severity'),f.get('category')) for f in fs))
for f in fs:
if f.get('severity')!='advisory': print(json.dumps(f)[:600])
"cd /vercel/sandbox && python -c "
import json;fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
for f in fs:
if f.get('category') in ('unlocked_anchor','freeze_panes','font_consistency','alignment'): print(json.dumps(f)[:400])
" | head -32cd /vercel/sandbox && python - <<'EOF'
import re
s=open("build_workbook.py").read()
# 1. no hard line breaks in headers -> rely on wrap_text
s = s.replace('\\n', ' ') # careful: only header label strings contain \n literals in source
# restore intentional newlines in python code (the replace above hit source escapes) -> re-read approach
s = open("build_workbook.py").read()
def strip_nl(m):
return m.group(0).replace("\\n", " ")
# only inside the cols = [...] list blocks
s = re.sub(r"cols = \[.*?\]\n", strip_nl, s, flags=re.S)
# 2. Inputs: single body font size 10, drop freeze panes
s = s.replace('Font(name="Arial", size=9, italic=True)', 'Font(name="Arial", size=10, italic=True)')
s = s.replace('Font(name="Arial", size=9, italic=True, color="595959")', 'Font(name="Arial", size=10, italic=True, color="595959")')
s = s.replace('setw(ws, {"A": 3, "B": 42, "C": 14, "D": 58})\nws.freeze_panes = "A4"',
'setw(ws, {"A": 3, "B": 42, "C": 14, "D": 58})')
# 3. lock intra-row column anchors on Debt_DSCR and Supply_Crossover formula dicts
def lock(block):
return re.sub(r'(?<![$A-Za-z0-9_!])([A-Z])\{row\}', r'$\1{row}', block)
for marker in [" f = {\n 1: f\"={md}A{row}\",\n 2: f\"={md}B{row}\",\n 3: f\"={md}C{row}/Inputs!$C$16\"",
" f = {\n 1: f\"={md}A{row}\",\n 2: f\"={md}N{row}\""]:
pass
start = s.index(' f = {\n 1: f"={md}A{row}",\n 2: f"={md}B{row}"')
end = s.index(' ffmt = {2: NUM0, 3: NUM1')
blk = s[start:end]
blk2 = re.sub(r'(?<![$\w!])([A-Z])\{row\}(?=[),/*+\-\s}])', r'$\1{row}', blk)
blk2 = blk2.replace('{md}$', '{md}') # md refs stay relative-row but keep column relative? lock them too
s = s[:start] + blk2 + s[end:]
start = s.index(' f = {\n 1: f"={md}A{row}",\n 2: f"={md}N{row}"')
end = s.index(' ffmt = {2: MONEY, 3: PCT1, 4: PCT1, 5: MONEY, 6: MONEY, 7: PCT2, 8: MONEY')
blk = s[start:end]
blk2 = re.sub(r'(?<![$\w!])([A-Z])\{row\}(?=[),/*+\-\s}])', r'$\1{row}', blk)
s = s[:start] + blk2 + s[end:]
# 4. right-align stress grid column headers
s = s.replace(' c.alignment = CTR\n ws.row_dimensions[top + 1].height = 30',
' c.alignment = Alignment(horizontal="right", vertical="center")\n ws.row_dimensions[top + 1].height = 30')
open("build_workbook.py","w").write(s)
EOF
grep -n 'md}A{row}' -A22 build_workbook.py | sed -n '1,60p'cd /vercel/sandbox && python - <<'EOF'
import re
s=open("build_workbook.py").read()
def fix(start_marker, end_marker):
global s
a=s.index(start_marker); b=s.index(end_marker)
blk=s[a:b]
blk=re.sub(r'(?<![$\w!])([A-Z])\{row\}', r'$\1{row}', blk)
s=s[:a]+blk+s[b:]
fix(' f = {\n 1: f"={md}A{row}",\n 2: f"={md}B{row}"', ' ffmt = {2: NUM0, 3: NUM1')
fix(' f = {\n 1: f"={md}A{row}",\n 2: f"={md}N{row}"', ' ffmt = {2: MONEY, 3: PCT1, 4: PCT1, 5: MONEY, 6: MONEY, 7: PCT2, 8: MONEY')
open("build_workbook.py","w").write(s)
EOF
python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json >/dev/null 2>&1; echo "SCAN EXIT: $?"; python -c "
import json;from collections import Counter
fs=json.load(open('output/integrity.json'));fs=fs.get('findings',fs)
print(Counter((f.get('severity'),f.get('category')) for f in fs))
"cd /vercel/sandbox && python skills/xlsx/scripts/recalc.py sunbelt_supply_rolloff_screen.xlsx 2>&1 | tail -20
cd /vercel/sandbox && python - <<'EOF'
from openpyxl import load_workbook
wb=load_workbook("sunbelt_supply_rolloff_screen.xlsx",data_only=True)
sc=wb["Supply_Crossover"]
print("=== Supply_Crossover ===")
print(f"{'row':<4}{'Market':<18}{'PipeMo':>8}{'S/D':>7}{'Spread':>8}{'OccΔ':>8}{'Trade':>8}{'Vac':>7}{'Score':>8}{'Rk':>4} Label")
for r in range(4,12):
v=[sc.cell(row=r,column=c).value for c in (1,5,7,9,11,12,13,19,20,21)]
print(f"{r:<4}{v[0]:<18}{v[1]:>8.1f}{v[2]:>7.2f}{v[3]:>8.0f}{v[4]:>8.0f}{v[5]*100:>7.2f}%{v[6]*100:>6.1f}%{v[7]:>8.1f}{v[8]:>4.0f} {v[9]}")
d=wb["Debt_DSCR"]
print("\n=== Debt_DSCR ===")
print(f"{'row':<4}{'Market':<18}{'NOI/u':>9}{'Val/u':>10}{'LTVpr':>10}{'DSCRpr':>10}{'Loan':>10}{'Bind':>6}{'LTV':>7}{'DSCR':>7}{'DebtY':>7}{'CoC':>8}{'StressVal':>11}{'ΔVal':>8}")
for r in range(4,12):
v=[d.cell(row=r,column=c).value for c in (1,6,8,9,10,11,12,13,15,16,18,19,20)]
print(f"{r:<4}{v[0]:<18}{v[1]:>9.0f}{v[2]:>10.0f}{v[3]:>10.0f}{v[4]:>10.0f}{v[5]:>10.0f}{v[6]:>6}{v[7]*100:>6.1f}%{v[8]:>7.2f}{v[9]*100:>6.2f}%{v[10]*100:>7.2f}%{v[11]:>11.0f}{v[12]*100:>7.1f}%")
g=wb["Stress_Grids"]
print("\n=== Stress grids (Tampa) === NOI/u",g["C5"].value,"cap",g["C6"].value,"pipe",g["C7"].value)
for top,lab in ((10,"CoC"),(20,"DSCR")):
print(lab, [round(g.cell(row=top+1,column=c).value,4) for c in range(3,9)])
for i in range(6):
r=top+2+i
print(" ",g.cell(row=r,column=2).value,[round(g.cell(row=r,column=c).value,4) for c in range(3,9)])
print("mortgage const",wb["Inputs"]["C11"].value)
EOFcd /vercel/sandbox && sed -i 's/"=-PMT($C$6\/12,$C$7\*12,-1)\*12"/"=PMT($C$6\/12,$C$7*12,-1)*12"/; s/(-PMT({cl}${hrow}\/12,Inputs!$C$7\*12,-1)\*12)/(PMT({cl}${hrow}\/12,Inputs!$C$7*12,-1)*12)/g' build_workbook.py && grep -n "PMT" build_workbook.pycd /vercel/sandbox && python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json >/dev/null 2>&1 && python skills/xlsx/scripts/recalc.py sunbelt_supply_rolloff_screen.xlsx | tail -5 && python - <<'EOF'
from openpyxl import load_workbook
wb=load_workbook("sunbelt_supply_rolloff_screen.xlsx",data_only=True)
print("mortgage const",round(wb["Inputs"]["C11"].value,5))
d=wb["Debt_DSCR"]
print(f"{'row':<4}{'Market':<18}{'NOI/u':>9}{'Val/u':>10}{'LTVpr':>10}{'DSCRpr':>10}{'Loan':>10}{'Bind':>6}{'LTV':>7}{'DSCR':>7}{'DebtY':>7}{'Eq/u':>9}{'CoC':>8}{'StressV':>10}{'dVal':>8}{'LTVstr':>8}")
for r in range(4,12):
v=[d.cell(row=r,column=c).value for c in (1,6,8,9,10,11,12,13,15,16,17,18,19,20,21)]
print(f"{r:<4}{v[0]:<18}{v[1]:>9.0f}{v[2]:>10.0f}{v[3]:>10.0f}{v[4]:>10.0f}{v[5]:>10.0f}{v[6]:>6}{v[7]*100:>6.1f}%{v[8]:>7.2f}{v[9]*100:>6.2f}%{v[10]:>9.0f}{v[11]*100:>7.2f}%{v[12]:>10.0f}{v[13]*100:>7.1f}%{v[14]*100:>7.1f}%")
g=wb["Stress_Grids"]
for top,lab in ((10,"CoC"),(20,"DSCR@maxLTV")):
print("\n",lab,"cols:",[round(g.cell(row=top+1,column=c).value,4) for c in range(3,9)])
for i in range(6):
r=top+2+i
print(" ",g.cell(row=r,column=2).value,[round(g.cell(row=r,column=c).value,4) for c in range(3,9)])
EOFcd /vercel/sandbox && python - <<'EOF'
s=open("build_workbook.py").read()
s = s.replace('grid(10, "Cash-on-cash return at supportable proceeds (rows: cap-rate stress; columns: debt coupon)", coc, PCT2)',
'''ws["B9"] = ("In both grids the cap-rate stress is applied to the ENTRY basis: a wider cap means you buy cheaper, "
"so returns improve as you move down. The mark-to-market loss on an asset already owned at the market "
"cap is on the Debt_DSCR tab, columns S to U.")
ws["B9"].font = Font(name="Arial", size=10, italic=True)
grid(11, "Cash-on-cash return at supportable proceeds (rows: cap-rate stress; columns: debt coupon)", coc, PCT2)''')
s = s.replace('grid(20, "DSCR if sized to maximum LTV (rows: cap-rate stress; columns: debt coupon)", dscr, MULT)',
'''grid(21, "DSCR if sized to maximum LTV (rows: cap-rate stress; columns: debt coupon)", dscr, MULT)''')
s = s.replace('ws["B30"] = "Cells below the minimum DSCR on the Inputs tab are the combinations where the loan resizes down and equity rises."\nws["B30"].font = Font(name="Arial", size=10, italic=True)',
'''ws["B30"] = ("Cells below the minimum DSCR on the Inputs tab are the combinations where DSCR binds, the loan resizes "
"below maximum LTV, and equity rises. Cells at or above it are the combinations where LTV binds instead.")
ws["B30"].font = Font(name="Arial", size=10, italic=True)''')
open("build_workbook.py","w").write(s)
EOF
python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py sunbelt_supply_rolloff_screen.xlsx --json output/integrity.json >/dev/null 2>&1; echo "GATE:$?"; python skills/xlsx/scripts/recalc.py sunbelt_supply_rolloff_screen.xlsx | tail -4
python - <<'EOF'
from openpyxl import load_workbook
wb=load_workbook("sunbelt_supply_rolloff_screen.xlsx",data_only=True)
g=wb["Stress_Grids"]
print("Tampa CoC grid hdr",[g.cell(row=12,column=c).value for c in range(3,9)])
for i in range(6):
r=13+i; print(g.cell(row=r,column=2).value,[round(g.cell(row=r,column=c).value,4) for c in range(3,9)])
print("DSCR hdr",[g.cell(row=22,column=c).value for c in range(3,9)])
for i in range(6):
r=23+i; print(g.cell(row=r,column=2).value,[round(g.cell(row=r,column=c).value,4) for c in range(3,9)])
EOFJacksonville is the only clean crossover in the group; Austin is the one where the cap rate has already gone through the Treasury, and it still has 15,000 units to deliver.
The screen's central finding is not about supply at all — it's that at a 6.04% agency coupon, DSCR binds before LTV in all eight markets, at implied leverage of 54% to 63% against a 65% quote. None of these markets support a 65% loan on in-place NOI. That reframes the question: the buyer entering now is an equity-heavy buyer, so cap-rate spread does most of the work in the return and the supply read is really about how long you have to fund negative tradeouts before rent growth returns.
| Market | Crossover score | Pipeline months remaining | Supply/demand ratio | Cap-rate spread to 5.0% UST (bps) | Occupancy (latest) | Occupancy change past 6 mo (bps) | New-lease tradeout % | Vacancy rate | Cycle label |
|---|---|---|---|---|---|---|---|---|---|
| 67.5 | 5.91 | 1.08x1 | +54 | 93.6% | -116 | +0.52% | 10.8% | Relief underway | |
| 57.6 | 3.6 | 1.75x | +73 | 91.4% | -194 | -3.60% | 15.7% | Still oversupplied | |
| 55.0 | 5.4 | 1.28x | +53 | 92.5% | -279 | +0.11% | 10.2% | Relief underway | |
| 50.2 | 5.8 | 1.40x | +25 | 93.1% | -135 | -2.97% | 11.6% | Turning | |
| 49.4 | 8.9 | 1.80x | +30 | 94.1% | -158 | -0.95% | 11.0% | Relief underway | |
| 42.0 | 11.2 | 1.07x | +18 | 92.2% | -193 | -0.21% | 10.3% | Relief underway | |
| 41.2 | 5.5 | 1.80x | +14 | 91.4% | -291 | -1.68% | 12.3% | Still oversupplied | |
| 37.0 | 8.7 | 0.97x | -102 | 92.3% | -119 | -3.58% | 12.7% | Still oversupplied |
Austin is the flag. Its multifamily cap rate is 4.90%, ten basis points through the 5.0% Treasury — the only market in the set trading below the risk-free rate. Pricing has already discounted a recovery Austin hasn't earned: tradeouts are running -3.58%, vacancy is 12.7%, and 15,174 units are still under construction. Austin is genuinely the furthest along on the supply flow — deliveries have fallen to 0.97x absorption, the only market where the crossover has technically happened — but you are being asked to pay for that in full, before rents confirm it. Cash-on-cash at market cap pencils to 2.14%, the thinnest in the group.
Three things the ranking turns on:
Where the debt math breaks. Sized to a 1.25x DSCR floor at a 6.04% coupon and 30-year amortization, every market resizes below the 65% LTV quote — Austin down to 54.3%, San Antonio the most generous at 63.4%. Debt yield lands at 9.03% everywhere, comfortably clear of an 8% floor, which confirms the coupon is the constraint rather than credit quality. Cash-on-cash runs 2.14% to 3.13% across the group. Taking Tampa as the worked example, LTV only becomes the binding constraint once entry caps widen roughly 75 to 100 bps from today: at +50 bps and a 6.04% coupon, DSCR at maximum LTV is still 1.23x, just under the floor.
The memo call
Build a pipeline now: Jacksonville and San Antonio. Jacksonville is the low-variance version — supply flow has already crossed, tradeouts are positive, and 54 bps of spread pays for the equity you're forced to write. San Antonio is the high-variance version and the better return if you can hold: its construction tap closes inside four months, the spread is the widest in the set, and by the time a deal sourced today closes, the delivery pressure that created the 15.7% vacancy will be gone. Underwrite it off the 15.7%, not a stabilized 6%. If you cannot carry three quarters of negative tradeouts, substitute Orlando — 53 bps of spread and positive tradeouts, but 1.28x deliveries-to-absorption means it's still adding supply.
Wait: Austin and Charlotte. Austin because the cap rate has already priced the recovery and gone through the Treasury, with 15,000 units still to come — revisit if the cap widens 50 bps or tradeouts turn positive, whichever comes first. Charlotte because 14 bps of spread does not pay for 16,500 units under construction and the fastest occupancy erosion in the group. Nashville is a third wait, on timing rather than pricing: nothing is wrong with it today, and 11 months of pipeline is still in front of it.
The workbook above carries debt cost, DSCR minimum, max LTV, cap-rate stress, and the composite weights as blue input cells — the ranking, the debt sizing, and both stress grids reprice off them. Equal weight, one quarter each, across pipeline months (inverted), spread, six-month occupancy change, and tradeout.
The single number that would move this ranking most is the reporting basis on absorption: C&W's YTD figures cover 1Q26, so the delivery and absorption paces are one quarter of data annualized. Cell C16 on the Inputs tab is that divisor — if you have a 2Q26 MarketBeats set, drop it in and the pipeline months recompute.
This is a read-only snapshot of a RealAI analysis.