I own Stacks on Main in Nashville. Based on cap rate trends, the rent growth forecast, and the supply pipeline, should I sell now or hold through 2029?
Now let me pull property data, market cap rates, rent time series, and permits in parallel.
cat /vercel/sandbox/skills/forecasting/references/request.md 2>/dev/null | head -300
mkdir -p /tmp/fc cat > /tmp/fc/caprate_raw.json << 'ENDCAPRATE' PLACEHOLDER ENDCAPRATE echo done
cat > /tmp/fc/build_caprate.py << 'EOF'
import json
# Nashville MF cap rate, full history from caprate_ts (quarterly), market_id 34980
data = [
("2005-01-01",0.0714),("2005-04-01",0.0693),("2005-07-01",0.0670),("2005-10-01",0.0659),
("2006-01-01",0.0658),("2006-04-01",0.0691),("2006-07-01",0.0695),("2006-10-01",0.0701),
("2007-01-01",0.0700),("2007-04-01",0.0682),("2007-07-01",0.0665),("2007-10-01",0.0687),
("2008-01-01",0.0686),("2008-04-01",0.0686),("2008-07-01",0.0795),("2008-10-01",0.0839),
("2009-01-01",0.0851),("2009-04-01",0.0852),("2009-07-01",0.0798),("2009-10-01",0.0744),
("2010-01-01",0.0745),("2010-04-01",0.0706),("2010-07-01",0.0656),("2010-10-01",0.0644),
("2011-01-01",0.0675),("2011-04-01",0.0660),("2011-07-01",0.0638),("2011-10-01",0.0637),
("2012-01-01",0.0659),("2012-04-01",0.0658),("2012-07-01",0.0669),("2012-10-01",0.0668),
("2013-01-01",0.0662),("2013-04-01",0.0650),("2013-07-01",0.0663),("2013-10-01",0.0676),
("2014-01-01",0.0675),("2014-04-01",0.0674),("2014-07-01",0.0653),("2014-10-01",0.0642),
("2015-01-01",0.0632),("2015-04-01",0.0618),("2015-07-01",0.0608),("2015-10-01",0.0581),
("2016-01-01",0.0571),("2016-04-01",0.0571),("2016-07-01",0.0571),("2016-10-01",0.0571),
("2017-01-01",0.0571),("2017-04-01",0.0569),("2017-07-01",0.0569),("2017-10-01",0.0568),
("2018-01-01",0.0567),("2018-04-01",0.0557),("2018-07-01",0.0557),("2018-10-01",0.0557),
("2019-01-01",0.0540),("2019-04-01",0.0540),("2019-07-01",0.0498),("2019-10-01",0.0498),
("2020-01-01",0.0471),("2020-04-01",0.0496),("2020-07-01",0.0455),("2020-10-01",0.0434),
("2021-01-01",0.0434),("2021-04-01",0.0382),("2021-07-01",0.0355),("2021-10-01",0.0343),
("2022-01-01",0.0358),("2022-04-01",0.0390),("2022-07-01",0.0399),("2022-10-01",0.0475),
("2023-01-01",0.0490),("2023-04-01",0.0490),("2023-07-01",0.0554),("2023-10-01",0.0523),
("2024-01-01",0.0523),("2024-04-01",0.0498),("2024-07-01",0.0482),("2024-10-01",0.0492),
("2025-01-01",0.0492),("2025-04-01",0.0517),("2025-07-01",0.0518),("2025-10-01",0.0518),
("2026-01-01",0.0518),("2026-04-01",0.0518),
]
history = [{"period": d, "value": round(v*100,2), "source": "datamart"} for d,v in data]
count = len(history)
s = sum(h["value"] for h in history)
last = history[-1]["value"]
first = history[0]["value"]
payload = {
"metric": {"name": "caprate_ts.multifamily", "units": "%", "family": "capital_markets"},
"subject": {"entity_type": "market", "entity_id": "34980", "label": "Nashville, TN MSA"},
"horizon": {"years": 3, "intervals": "annual"},
"as_of": "2026-09-11",
"history": history,
"history_check": {"count": count, "sum": round(s,2), "first_value": first, "last_value": last},
"caller_disclosures": {
"lookback_note": "full available history 2005-2026 (quarterly, 87 obs) per directional-mode rule",
"rate_instrument": "fed_funds"
},
"context_signals": {
"rate_environment": {
"current_rate": 3.63,
"projected_rate": 3.4,
"projected_as_of": "2028",
"note": "Fed funds actual Aug 2026 vs FOMC median projection for 2028"
}
},
"scenarios": ["base"]
}
with open("/tmp/fc/caprate_payload.json","w") as f:
json.dump(payload, f)
print(count, s, last)
EOF
python3 /tmp/fc/build_caprate.pypython3 /vercel/sandbox/skills/forecasting/scripts/forecast.py --file /tmp/fc/caprate_payload.json > /tmp/fc/caprate_out.json cat /tmp/fc/caprate_out.json
cat > /tmp/fc/build_rent.py << 'EOF'
import json
# Nashville MSA mf_rent_ts asking_rent_latest_avg, monthly, Jul2024-Aug2026
data = [
("2024-07-01",1799.584362),("2024-08-01",1801.424332),("2024-09-01",1801.214563),
("2024-10-01",1794.473364),("2024-11-01",1780.109834),("2024-12-01",1770.912062),
("2025-01-01",1769.548549),("2025-02-01",1800.276724),("2025-03-01",1808.029050),
("2025-04-01",1814.714893),("2025-05-01",1825.408853),("2025-06-01",1831.680138),
("2025-07-01",1827.930050),("2025-08-01",1837.415524),("2025-09-01",1840.899143),
("2025-10-01",1854.803583),("2025-11-01",1849.245502),("2025-12-01",1865.213900),
("2026-01-01",1874.422468),("2026-02-01",1864.523219),("2026-03-01",1856.635031),
("2026-04-01",1872.545404),("2026-05-01",1886.223971),("2026-06-01",1894.898530),
("2026-07-01",1892.686675),("2026-08-01",1887.510100),
]
history = [{"period": d, "value": round(v,2), "source": "datamart"} for d,v in data]
count = len(history)
s = sum(h["value"] for h in history)
last = history[-1]["value"]
first = history[0]["value"]
payload = {
"metric": {"name": "mf_rent_ts.asking_rent_latest_avg", "units": "$", "family": "rent_or_occupancy"},
"subject": {"entity_type": "market", "entity_id": "34980", "label": "Nashville, TN MSA"},
"horizon": {"years": 3, "intervals": "annual"},
"as_of": "2026-09-11",
"requested_lookback_years": 2,
"history": history,
"history_check": {"count": count, "sum": round(s,2), "first_value": first, "last_value": last},
"caller_disclosures": {
"peer_omitted_reason": "not gathered",
"sibling_series_note": "in_place_rent_latest_avg checked in same fetch: rose from 1625.22 (Jul24) to 1693.87 (Aug26), a slower climb than asking rent",
"sibling_divergence_pct": 0.0,
"lookback_note": "only 26 months of monthly mf_rent_ts history available at MSA grain (series starts Jul 2024); full available history used"
},
"context_signals": {
"supply_pipeline": {
"existing_stock": 177268,
"permitted_units_t13_t24": 2903
}
},
"scenarios": ["base", "upside", "downside"]
}
with open("/tmp/fc/rent_payload.json","w") as f:
json.dump(payload, f)
print(count, s, last, first)
EOF
python3 /tmp/fc/build_rent.py
python3 /vercel/sandbox/skills/forecasting/scripts/forecast.py --file /tmp/fc/rent_payload.json > /tmp/fc/rent_out.json
cat /tmp/fc/rent_out.jsoncat /vercel/sandbox/skills/mf-operating-statement/references/schema.md | head -220
cat > /tmp/fc/req_directcap_now.json << 'EOF'
{
"mode": "direct_cap",
"property_context": {
"units": 268,
"in_place_rent": 1906.77,
"market_rent": 1912.58,
"occupancy": 0.9328,
"stabilization_status_override": "stabilized"
},
"mf_property_financials": {
"gpr": 6132833.77,
"vacancy_loss": -217740.35,
"other_income": 933014.20,
"egi": 6848107.62,
"opex": 3751530.42,
"noi": 3096577.20
},
"benchmarks": {"vacancy_rate": 0.067, "opex_ratio": 0.5809},
"valuation_inputs": {
"going_in_cap": 0.0518,
"exit_cap": 0.0518,
"discount_rate": 0.085,
"sale_costs_pct": 0.02,
"purchase_price": 68000000
}
}
EOF
python3 skills/mf-operating-statement/scripts/operating.py < /tmp/fc/req_directcap_now.json > /tmp/fc/out_directcap_now.json
cat /tmp/fc/out_directcap_now.jsoncat > /tmp/fc/req_directcap_actual_occ.py << 'EOF'
import json, subprocess
gpr = 6132833.77
actual_vacancy_rate = 1 - 0.9328 # actual physical vacancy per rent index
vacancy_loss = -round(gpr * actual_vacancy_rate, 2)
net_rent = gpr + vacancy_loss
# scale other income down proportionally to occupancy shift vs the financials' implied occupancy
implied_occ_in_financials = 1 - (217740.35/gpr)
other_income = round(933014.20 * (0.9328/implied_occ_in_financials), 2)
egi = round(net_rent + other_income, 2)
opex = 3751530.42 # held flat - largely fixed costs, insurance/tax/payroll don't scale down with occupancy dip
noi = round(egi - opex, 2)
req = {
"mode": "direct_cap",
"property_context": {
"units": 268,
"in_place_rent": 1906.77,
"market_rent": 1912.58,
"occupancy": 0.9328,
"stabilization_status_override": "stabilized"
},
"mf_property_financials": {
"gpr": gpr,
"vacancy_loss": vacancy_loss,
"other_income": other_income,
"egi": egi,
"opex": opex,
"noi": noi
},
"benchmarks": {"vacancy_rate": actual_vacancy_rate, "opex_ratio": round(opex/egi,4)},
"valuation_inputs": {
"going_in_cap": 0.0518,
"exit_cap": 0.0518,
"discount_rate": 0.085,
"sale_costs_pct": 0.02,
"purchase_price": 68000000
}
}
with open("/tmp/fc/req_actual_occ.json","w") as f:
json.dump(req, f)
print(json.dumps(req, indent=2))
EOF
python3 /tmp/fc/req_directcap_actual_occ.py
python3 skills/mf-operating-statement/scripts/operating.py < /tmp/fc/req_actual_occ.json > /tmp/fc/out_actual_occ.json
cat /tmp/fc/out_actual_occ.jsoncat > /tmp/fc/req_dcf_hold.json << 'EOF'
{
"mode": "dcf",
"property_context": {
"units": 268,
"in_place_rent": 1906.77,
"market_rent": 1912.58,
"occupancy": 0.9328,
"stabilization_status_override": "stabilized"
},
"mf_property_financials": {
"gpr": 6132833.77,
"vacancy_loss": -412126.43,
"other_income": 902352.81,
"egi": 6623060.15,
"opex": 3751530.42,
"noi": 2871529.73
},
"benchmarks": {"vacancy_rate": 0.0672, "opex_ratio": 0.5664},
"projection_inputs": {
"horizon_years": 3,
"rent_growth": 0.029,
"expense_growth": 0.025,
"other_income_growth": 0.029,
"target_vacancy": 0.06
},
"valuation_inputs": {
"going_in_cap": 0.0518,
"exit_cap": 0.0518,
"discount_rate": 0.085,
"sale_costs_pct": 0.02,
"purchase_price": 68000000
}
}
EOF
python3 skills/mf-operating-statement/scripts/operating.py < /tmp/fc/req_dcf_hold.json > /tmp/fc/out_dcf_hold.json
cat /tmp/fc/out_dcf_hold.json | python3 -m json.tool 2>/dev/null || cat /tmp/fc/out_dcf_hold.jsonfor exitcap in 0.0480 0.0550; do
python3 - <<PYEOF
import json
req = json.load(open("/tmp/fc/req_dcf_hold.json"))
req["valuation_inputs"]["exit_cap"] = $exitcap
json.dump(req, open("/tmp/fc/req_dcf_$exitcap.json","w"))
PYEOF
python3 skills/mf-operating-statement/scripts/operating.py < /tmp/fc/req_dcf_$exitcap.json > /tmp/fc/out_dcf_$exitcap.json
echo "=== exit_cap $exitcap ==="
python3 -c "import json; d=json.load(open('/tmp/fc/out_dcf_$exitcap.json')); print(json.dumps(d['valuation'], indent=2))"
donecat > /tmp/fc/build_wb.py << 'PYEOF'
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
wb = openpyxl.Workbook()
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)
HDR_FILL = PatternFill(start_color="1F3864", end_color="1F3864", fill_type="solid")
HDR_FONT = Font(name="Arial", size=11, bold=True, color="FFFFFF")
SUBHDR_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
TITLE_FONT = Font(name="Arial", size=14, bold=True)
TOTAL_BORDER = Border(top=Side(style="thin"))
DOLLAR = '$#,##0;($#,##0);"-"'
DOLLAR2 = '$#,##0.00;($#,##0.00);"-"'
PCT1 = '0.0%;(0.0%);"-"'
PCT2 = '0.00%;(0.00%);"-"'
NUM = '#,##0;(#,##0);"-"'
MULT = '0.00x'
def style_header(ws, row, col1, col2):
for c in range(col1, col2+1):
cell = ws.cell(row=row, column=c)
cell.fill = HDR_FILL
cell.font = HDR_FONT
cell.alignment = Alignment(horizontal="center", vertical="center")
def set_col_widths(ws, widths):
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
# ============ SHEET 1: ASSUMPTIONS ============
ws = wb.active
ws.title = "Assumptions"
set_col_widths(ws, [38, 16, 30])
ws["A1"] = "Stacks on Main — Sell vs. Hold Assumptions"
ws["A1"].font = TITLE_FONT
ws.merge_cells("A1:C1")
rows = [
("Property Basics", None, None, "header"),
("Units", 268, "property_mfr.mf_property_attributes", "input"),
("Purchase price ($)", 68000000, "property_mfr.mf_sales_history (11/26/2025)", "input"),
("Purchase date", "11/26/2025", "property_mfr.mf_sales_history", "input"),
("Analysis date", "9/11/2026", "as of", "input"),
("Current Operations (actual physical occupancy basis)", None, None, "header"),
("Gross potential rent, annualized ($)", 6132833.77, "property_mfr.mf_property_financials", "input"),
("Actual physical vacancy rate", 0.0672, "1 - occupancy_latest (93.28%), mf_rent_and_occupancy_detail", "input"),
("Other income, annualized ($, occupancy-adjusted)", 902352.81, "mf_property_financials, scaled to actual occupancy", "input"),
("Operating expenses, annualized ($)", 3751530.42, "property_mfr.mf_property_financials", "input"),
("Market & Forecast Inputs", None, None, "header"),
("Current Nashville MF cap rate", 0.0518, "market.caprate_ts, Q2 2026", "input"),
("Cap rate — compression (upside) scenario", 0.0480, "forecast.py directional read: mid full-history band, easing rate bias", "input"),
("Cap rate — flat (base) scenario", 0.0518, "forecast.py directional read: rich vs trailing-10yr band", "input"),
("Cap rate — widening (downside) scenario", 0.0550, "forecast.py directional read: stale-series flag, supply overhang risk", "input"),
("Rent/NOI growth rate (annual)", 0.029, "forecast.py base case, Nashville MSA asking rent, Yr1-3 avg", "input"),
("Expense growth rate (annual)", 0.025, "mf-operating-statement default", "input"),
("Target/stabilized vacancy rate", 0.06, "mf-operating-statement default", "input"),
("Discount rate (unlevered)", 0.085, "mf-operating-statement default", "input"),
("Sale costs (% of gross price)", 0.02, "mf-operating-statement default", "input"),
("Hold horizon (years, to 2029)", 3, "user question horizon", "input"),
]
r = 3
for label, val, src, kind in rows:
if kind == "header":
ws.cell(row=r, column=1, value=label).font = BOLD
ws.cell(row=r, column=1).fill = SUBHDR_FILL
ws.cell(row=r, column=2).fill = SUBHDR_FILL
ws.cell(row=r, column=3).fill = SUBHDR_FILL
r += 1
continue
ws.cell(row=r, column=1, value=label)
c = ws.cell(row=r, column=2, value=val)
c.font = BLUE
if "rate" in label.lower() or "%" in label or "vacancy" in label.lower() or "growth" in label.lower() or "discount" in label.lower() or "costs" in label.lower():
c.number_format = PCT2 if "cap rate" in label.lower() else PCT1
elif "$" in label:
c.number_format = DOLLAR2 if "annualized" in label and "." in str(val) else DOLLAR
elif label == "Units" or label == "Hold horizon (years, to 2029)":
c.number_format = NUM
ws.cell(row=r, column=3, value=src).font = Font(name="Arial", size=9, italic=True, color="666666")
r += 1
# Named cell refs for later use (row numbers)
refs = {}
r2 = 3
for label, val, src, kind in rows:
if kind != "header":
refs[label] = r2
r2 += 1
wb.save("/tmp/fc/stacks_on_main_sell_hold.xlsx")
print("assumptions rows:", refs)
PYEOF
python3 /tmp/fc/build_wb.pyNow let's build the projection and decision sheets with live formulas referencing these input cells.
cat > /tmp/fc/build_wb2.py << 'PYEOF'
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
wb = openpyxl.load_workbook("/tmp/fc/stacks_on_main_sell_hold.xlsx")
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)
BOLD_BORDER = Border(top=Side(style="thin"))
HDR_FILL = PatternFill(start_color="1F3864", end_color="1F3864", fill_type="solid")
HDR_FONT = Font(name="Arial", size=11, bold=True, color="FFFFFF")
SUBHDR_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
TITLE_FONT = Font(name="Arial", size=14, bold=True)
DOLLAR = '$#,##0;($#,##0);"-"'
DOLLAR2 = '$#,##0.00;($#,##0.00);"-"'
PCT1 = '0.0%;(0.0%);"-"'
PCT2 = '0.00%;(0.00%);"-"'
NUM = '#,##0;(#,##0);"-"'
PERUNIT = '$#,##0'
def set_col_widths(ws, widths):
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
A = "Assumptions"
# ============ SHEET 2: PROJECTION (Base Year 2026 -> 2029) ============
ws = wb.create_sheet("Projection")
set_col_widths(ws, [30, 16, 16, 16, 16])
ws["A1"] = "Operating Statement Waterfall — Base Year Actuals Through 2029"
ws["A1"].font = TITLE_FONT
ws.merge_cells("A1:E1")
ws["A3"] = ""
ws["B3"] = "Year 0 (Base, 2026)"
ws["C3"] = "Year 1 (2027)"
ws["D3"] = "Year 2 (2028)"
ws["E3"] = "Year 3 (2029)"
for col in "BCDE":
ws[f"{col}3"].font = BOLD
ws[f"{col}3"].fill = SUBHDR_FILL
ws[f"{col}3"].alignment = Alignment(horizontal="center")
lines = ["Gross potential rent", "Vacancy loss", "Net rent", "Other income", "Effective gross income",
"Operating expenses", "Net operating income", "Vacancy rate (check)"]
for i, l in enumerate(lines):
ws.cell(row=4+i, column=1, value=l)
# Base year (Year 0) — column B, row refs: GPR=4, Vacancy=5, NetRent=6, OtherInc=7, EGI=8, Opex=9, NOI=10, VacRate=11
ws["B4"] = f"='{A}'!B9"
ws["B5"] = f"=-'{A}'!B9*'{A}'!B10"
ws["B6"] = "=B4+B5"
ws["B7"] = f"='{A}'!B11"
ws["B8"] = "=B6+B7"
ws["B9"] = f"='{A}'!B12"
ws["B10"] = "=B8-B9"
ws["B11"] = "=-B5/B4"
# Years 1-3 (columns C, D, E) grow GPR & other income by rent growth; opex by expense growth;
# vacancy rate converges to target over horizon (linear step toward target each year)
growth_col_map = {"C": "B", "D": "C", "E": "D"}
for col, prev in growth_col_map.items():
ws[f"{col}4"] = f"={prev}4*(1+'{A}'!$B$18)"
# vacancy rate steps 1/3 of the way from prior year's rate toward target each year
ws[f"{col}11"] = f"={prev}11+(('{A}'!$B$20-{prev}11)/3)"
ws[f"{col}5"] = f"=-{col}4*{col}11"
ws[f"{col}6"] = f"={col}4+{col}5"
ws[f"{col}7"] = f"={prev}7*(1+'{A}'!$B$18)"
ws[f"{col}8"] = f"={col}6+{col}7"
ws[f"{col}9"] = f"={prev}9*(1+'{A}'!$B$19)"
ws[f"{col}10"] = f"={col}8-{col}9"
for row in range(4, 12):
for col in "BCDE":
cell = ws[f"{col}{row}"]
cell.number_format = DOLLAR if row != 11 else PCT2
cell.font = BLACK
ws.cell(row=row, column=1).font = BOLD if row in (6, 8, 10) else Font(name="Arial", size=10)
ws["B10"].font = Font(name="Arial", size=10, bold=True)
for col in "CDE":
ws[f"{col}10"].font = Font(name="Arial", size=10, bold=True)
for col in "BCDE":
ws[f"{col}10"].border = BOLD_BORDER
ws.cell(row=10, column=1).font = BOLD
ws["A13"] = "Note: Year-0 base reflects current actual physical occupancy (93.28%), not the stabilized pro forma vacancy in the source financials (3.55%) — see analysis for reconciliation."
ws["A13"].font = Font(name="Arial", size=9, italic=True, color="666666")
ws.merge_cells("A13:E13")
wb.save("/tmp/fc/stacks_on_main_sell_hold.xlsx")
print("Projection sheet built")
PYEOF
python3 /tmp/fc/build_wb2.pyNow the decision sheet with the sell-now vs hold sensitivity grid and IRR.
cat > /tmp/fc/build_wb3.py << 'PYEOF'
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
wb = openpyxl.load_workbook("/tmp/fc/stacks_on_main_sell_hold.xlsx")
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)
BOLD_BORDER = Border(top=Side(style="thin"))
HDR_FILL = PatternFill(start_color="1F3864", end_color="1F3864", fill_type="solid")
HDR_FONT = Font(name="Arial", size=11, bold=True, color="FFFFFF")
SUBHDR_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
TITLE_FONT = Font(name="Arial", size=14, bold=True)
YELLOW = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
DOLLAR = '$#,##0;($#,##0);"-"'
PCT1 = '0.0%;(0.0%);"-"'
PCT2 = '0.00%;(0.00%);"-"'
PERUNIT = '$#,##0'
def set_col_widths(ws, widths):
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
A = "Assumptions"
P = "Projection"
ws = wb.create_sheet("Sell vs Hold Decision")
set_col_widths(ws, [34, 18, 18, 18, 18])
ws["A1"] = "Sell Now vs. Hold to 2029 — Decision Grid"
ws["A1"].font = TITLE_FONT
ws.merge_cells("A1:E1")
# --- Sell Now block ---
ws["A3"] = "Sell Now (current basis)"
ws["A3"].font = BOLD
ws["A3"].fill = SUBHDR_FILL
ws.merge_cells("A3:B3")
ws["A4"] = "Current NOI (actual occupancy, Year 0)"
ws["B4"] = f"='{P}'!B10"
ws["A5"] = "Current market cap rate"
ws["B5"] = f"='{A}'!$B$14"
ws["A6"] = "Gross sale value (NOI / cap rate)"
ws["B6"] = "=B4/B5"
ws["A7"] = "Less: sale costs"
ws["B7"] = f"=-B6*'{A}'!$B$22"
ws["A8"] = "Net proceeds if sold now"
ws["B8"] = "=B6+B7"
ws["A9"] = "vs. purchase price paid (Nov 2025)"
ws["B9"] = f"='{A}'!$B$5"
ws["A10"] = "Gain / (loss) vs. basis"
ws["B10"] = "=B8-B9"
ws["A11"] = "Gain / (loss) %"
ws["B11"] = "=B10/B9"
for r in range(4, 12):
ws[f"B{r}"].font = BLACK
ws[f"B{r}"].number_format = PCT2 if r == 5 else (PCT1 if r == 11 else DOLLAR)
ws["B8"].font = Font(bold=True, name="Arial", size=10)
ws["B10"].font = Font(bold=True, name="Arial", size=10)
# --- Hold to 2029 scenario grid ---
ws["A14"] = "Hold to 2029 — Exit Cap Rate Scenarios"
ws["A14"].font = BOLD
ws["A14"].fill = SUBHDR_FILL
ws.merge_cells("A14:E14")
ws["A16"] = ""
ws["B16"] = "Compression (Low)"
ws["C16"] = "Flat (Base)"
ws["D16"] = "Widening (High)"
for col in "BCD":
ws[f"{col}16"].font = BOLD
ws[f"{col}16"].fill = SUBHDR_FILL
ws[f"{col}16"].alignment = Alignment(horizontal="center")
ws["A17"] = "Exit cap rate (2029)"
ws["B17"] = f"='{A}'!$B$15"
ws["C17"] = f"='{A}'!$B$16"
ws["D17"] = f"='{A}'!$B$17"
ws["A18"] = "Year-3 NOI (2029)"
ws["B18"] = f"='{P}'!E10"
ws["C18"] = f"='{P}'!E10"
ws["D18"] = f"='{P}'!E10"
ws["A19"] = "Gross exit value"
for col in "BCD":
ws[f"{col}19"] = f"={col}18/{col}17"
ws["A20"] = "Less: sale costs"
for col in "BCD":
ws[f"{col}20"] = f"=-{col}19*'{A}'!$B$22"
ws["A21"] = "Net exit proceeds"
for col in "BCD":
ws[f"{col}21"] = f"={col}19+{col}20"
ws["A22"] = "Cumulative NOI collected, Yr 1-3"
for col in "BCD":
ws[f"{col}22"] = f"=SUM('{P}'!C10:E10)"
ws["A23"] = "Total nominal value (NOI + net exit)"
for col in "BCD":
ws[f"{col}23"] = f"={col}21+{col}22"
ws["A24"] = "vs. Sell-Now net proceeds today"
for col in "BCD":
ws[f"{col}24"] = "=$B$8"
ws["A25"] = "Nominal pickup from holding"
for col in "BCD":
ws[f"{col}25"] = f"={col}23-{col}24"
for r in range(17, 26):
for col in "BCD":
cell = ws[f"{col}{r}"]
cell.font = BLACK
cell.number_format = PCT2 if r == 17 else DOLLAR
ws["A25"].font = BOLD
for col in "BCD":
ws[f"{col}25"].font = Font(bold=True, name="Arial", size=10)
ws[f"{col}25"].border = BOLD_BORDER
# --- IRR of holding vs. cashing out now ---
ws["A28"] = "IRR of Holding (cash flow: -Sell-Now proceeds today, +NOI Yr1-3, +Net exit proceeds Yr3)"
ws["A28"].font = BOLD
ws["A28"].fill = SUBHDR_FILL
ws.merge_cells("A28:E28")
ws["A30"] = ""
ws["B30"] = "Compression (Low)"
ws["C30"] = "Flat (Base)"
ws["D30"] = "Widening (High)"
for col in "BCD":
ws[f"{col}30"].font = BOLD
ws[f"{col}30"].alignment = Alignment(horizontal="center")
yrlabels = ["Year 0 (today, opportunity cost)", "Year 1", "Year 2", "Year 3 (NOI + net exit)"]
for i, lbl in enumerate(yrlabels):
ws.cell(row=31+i, column=1, value=lbl)
for col in "BCD":
ws[f"{col}31"] = "=-$B$8"
ws[f"{col}32"] = f"='{P}'!C10"
ws[f"{col}33"] = f"='{P}'!D10"
ws[f"{col}34"] = f"={col}22 -SUM('{P}'!C10:D10) + {col}21" # Year3 NOI + net exit proceeds
for r in range(31, 35):
for col in "BCD":
ws[f"{col}{r}"].font = BLACK
ws[f"{col}{r}"].number_format = DOLLAR
ws["A36"] = "Unlevered IRR of holding (vs. cashing out today)"
ws["A36"].font = BOLD
for col in "BCD":
ws[f"{col}36"] = f"=IRR({col}31:{col}34)"
ws[f"{col}36"].font = Font(bold=True, name="Arial", size=10)
ws[f"{col}36"].number_format = PCT2
ws[f"{col}36"].border = BOLD_BORDER
ws["A38"] = "Required unlevered discount rate (hurdle)"
ws["B38"] = f"='{A}'!$B$21"
ws["B38"].number_format = PCT2
ws["B38"].font = BLACK
wb.save("/tmp/fc/stacks_on_main_sell_hold.xlsx")
print("Decision sheet built")
PYEOF
python3 /tmp/fc/build_wb3.pyNow recalc, scan, and check results.
cd /vercel/sandbox python3 skills/xlsx/scripts/recalc.py /tmp/fc/stacks_on_main_sell_hold.xlsx python3 skills/xlsx/scripts/workbook_integrity_scan.py /tmp/fc/stacks_on_main_sell_hold.xlsx echo "EXIT: $?"
cat > /tmp/fc/fix_wb.py << 'PYEOF'
import openpyxl
wb = openpyxl.load_workbook("/tmp/fc/stacks_on_main_sell_hold.xlsx")
# Fix em-dashes across sheets
for wsname in wb.sheetnames:
ws = wb[wsname]
for row in ws.iter_rows():
for cell in row:
if isinstance(cell.value, str) and "\u2014" in cell.value:
cell.value = cell.value.replace("\u2014", "-")
ws = wb["Sell vs Hold Decision"]
ws["A4"] = "Current net operating income (actual occupancy, Year 0)"
ws["A18"] = "Year-3 net operating income (2029)"
ws["A32"] = "Year 1 net operating income"
ws["A33"] = "Year 2 net operating income"
# widen narrow columns
wb["Assumptions"].column_dimensions["A"].width = 50
wb["Projection"].column_dimensions["B"].width = 20
wb["Projection"].column_dimensions["C"].width = 14
wb["Projection"].column_dimensions["D"].width = 14
wb["Sell vs Hold Decision"].column_dimensions["A"].width = 42
wb["Sell vs Hold Decision"].column_dimensions["C"].width = 18
# unmerge the note/title merges that sit in data body except row-1 title banners
for wsname, rng in [("Projection", "A13:E13"), ("Sell vs Hold Decision", "A3:B3")]:
ws = wb[wsname]
ws.unmerge_cells(rng)
# fix font consistency: force Arial 10 for all body cells not already title/header
from openpyxl.styles import Font
for wsname in wb.sheetnames:
ws = wb[wsname]
for row in ws.iter_rows():
for cell in row:
if cell.value is None:
continue
f = cell.font
if f.size not in (14, 11) or f.bold:
# keep bold headers as-is but normalize size/name
pass
# Normalize any stray Calibri to Arial, keep size/bold/color
for row in ws.iter_rows():
for cell in row:
if cell.font and cell.font.name == "Calibri":
cell.font = Font(name="Arial", size=cell.font.size or 10, bold=cell.font.bold, color=cell.font.color, italic=cell.font.italic)
# bold total row 23 in Sell vs Hold Decision
from openpyxl.styles import Border, Side
bd = Border(top=Side(style="thin"))
for col in "ABCD":
c = ws[f"{col}23"] if wsname=="Sell vs Hold Decision" else None
ws2 = wb["Sell vs Hold Decision"]
for col in "ABCD":
ws2[f"{col}23"].font = Font(name="Arial", size=10, bold=True)
ws2[f"{col}23"].border = bd
wb.save("/tmp/fc/stacks_on_main_sell_hold.xlsx")
print("fixed")
PYEOF
python3 /tmp/fc/fix_wb.py
python3 skills/xlsx/scripts/recalc.py /tmp/fc/stacks_on_main_sell_hold.xlsx
python3 skills/xlsx/scripts/workbook_integrity_scan.py /tmp/fc/stacks_on_main_sell_hold.xlsx
echo "EXIT: $?"Gate passes. Let's read back the key values to cite accurately.
cd /vercel/sandbox
python3 -c "
import openpyxl
wb = openpyxl.load_workbook('/tmp/fc/stacks_on_main_sell_hold.xlsx', data_only=True)
s = wb['Sell vs Hold Decision']
print('Sell now block:')
for r in range(4,12):
print(r, s.cell(row=r,column=1).value, '=>', s.cell(row=r,column=2).value)
print()
print('Hold grid:')
for r in range(16,26):
print(r, [s.cell(row=r,column=c).value for c in range(1,5)])
print()
print('IRR block:')
for r in range(30,39):
print(r, [s.cell(row=r,column=c).value for c in range(1,5)])
p = wb['Projection']
print()
print('Projection:')
for r in range(3,12):
print(r, [p.cell(row=r,column=c).value for c in range(1,6)])
"
Hold — selling now locks in a ~20% loss on a market that's mid-cycle, not deteriorating
You bought this asset 10 months ago (Nov 2025) for $68.0M — $253,731/unit — at what worked out to a 4.55% in-place cap rate on trailing NOI. That was already rich versus where the market was pricing multifamily at the time, and the market hasn't come back to meet you: Nashville MF cap rates sit at 5.18% today, flat for four straight quarters. If you sold today on current actual NOI, you're looking at a $54.3M net sale price — a $13.7M (20.1%) loss against your basis.
That's the case against holding onto the mark reflexively. But the case for holding is stronger once you look at what's driving the pain and where it's headed:
| Metric | Sell Now | Hold to 2029 – Cap Compression (4.80%) | Hold to 2029 – Flat (5.18%) | Hold to 2029 – Cap Widening (5.50%) |
|---|---|---|---|---|
| Net Proceeds / Total Value | $54,326,2381 | $74,829,864 | $70,022,086 | $66,488,714 |
| Gain (Loss) vs $68M Basis ($) | -$13,673,762 | $6,829,864 | $2,022,086 | -$1,511,286 |
| Gain (Loss) vs $68M Basis (%) | -20.1% | 10.0% | 3.0% | -2.2% |
| Unlevered IRR vs 8.5% Hurdle | n/a | 11.81% | 9.28% | 7.33% |
Run the actual numbers through a 3-year hold and every cap-rate path tested — even the pessimistic one where cap rates widen another 30bps to 5.50% — comes out nominally ahead of selling today, by $12.2M to $20.5M. On a discounted basis against an 8.5% unlevered hurdle: the base case (flat cap rate) clears it at 9.3% IRR, compression clears it comfortably at 11.8%, and only the widening scenario falls short, at 7.3%.
One data flag worth knowing: the property's own financials show only a 3.55% vacancy assumption, but the rent-index tracking shows 93.3% physical occupancy (6.72% vacant) and falling — I built the model off the actual occupancy trend, not the more optimistic pro forma number, so the "sell now" value is the honest one, not a rosier stabilized figure.
Bottom line: hold. Selling now crystallizes a loss into a market that's neither cheap nor getting cheaper, right as the supply pipeline that caused your occupancy slide is drying up. The thing that would flip this call: if retention keeps sliding and occupancy breaks meaningfully below 90% with no sign of stabilizing over the next few quarters, or cap rates push past 5.5% on a real widening (not just the current stale print) — at that point the IRR math no longer clears the hurdle and cashing out becomes the better trade. Fix the leasing execution first; that's a bigger lever than the market right now.
This is a read-only snapshot of a RealAI analysis.