Generate a full multifamily market report for Raleigh as a polished Word document I could share with investors: fundamentals, supply pipeline, demand drivers, and a 3-year rent forecast.
Now let me pull the core data for Raleigh, plus identify peers.
cat /vercel/sandbox/skills/forecasting/references/request.md | head -200
Now let me build the payload script with the raw data.
cd /vercel/sandbox && python3 --version && python3 skills/forecasting/scripts/forecast.py --version
[
{"period_start_date":"2026-08-01","asking_rent_latest_avg":1678.619750,"in_place_rent_latest_avg":1542.531138},
{"period_start_date":"2026-07-01","asking_rent_latest_avg":1691.571087,"in_place_rent_latest_avg":1543.356964},
{"period_start_date":"2026-06-01","asking_rent_latest_avg":1692.053988,"in_place_rent_latest_avg":1541.960675},
{"period_start_date":"2026-05-01","asking_rent_latest_avg":1703.336279,"in_place_rent_latest_avg":1540.551458},
{"period_start_date":"2026-04-01","asking_rent_latest_avg":1687.132631,"in_place_rent_latest_avg":1539.173356},
{"period_start_date":"2026-03-01","asking_rent_latest_avg":1671.985017,"in_place_rent_latest_avg":1538.663662},
{"period_start_date":"2026-02-01","asking_rent_latest_avg":1685.121551,"in_place_rent_latest_avg":1540.162710},
{"period_start_date":"2026-01-01","asking_rent_latest_avg":1687.964758,"in_place_rent_latest_avg":1541.487856},
{"period_start_date":"2025-12-01","asking_rent_latest_avg":1688.240501,"in_place_rent_latest_avg":1543.883565},
{"period_start_date":"2025-11-01","asking_rent_latest_avg":1653.549830,"in_place_rent_latest_avg":1544.521814},
{"period_start_date":"2025-10-01","asking_rent_latest_avg":1650.512980,"in_place_rent_latest_avg":1546.599795},
{"period_start_date":"2025-09-01","asking_rent_latest_avg":1649.989547,"in_place_rent_latest_avg":1546.987437},
{"period_start_date":"2025-08-01","asking_rent_latest_avg":1667.112257,"in_place_rent_latest_avg":1548.777741},
{"period_start_date":"2025-07-01","asking_rent_latest_avg":1687.807068,"in_place_rent_latest_avg":1550.003164},
{"period_start_date":"2025-06-01","asking_rent_latest_avg":1695.201375,"in_place_rent_latest_avg":1550.200160},
{"period_start_date":"2025-05-01","asking_rent_latest_avg":1700.824641,"in_place_rent_latest_avg":1550.987136},
{"period_start_date":"2025-04-01","asking_rent_latest_avg":1729.948993,"in_place_rent_latest_avg":1548.195506},
{"period_start_date":"2025-03-01","asking_rent_latest_avg":1718.657041,"in_place_rent_latest_avg":1547.061591},
{"period_start_date":"2025-02-01","asking_rent_latest_avg":1718.249898,"in_place_rent_latest_avg":1546.291326},
{"period_start_date":"2025-01-01","asking_rent_latest_avg":1702.769390,"in_place_rent_latest_avg":1544.211397},
{"period_start_date":"2024-12-01","asking_rent_latest_avg":1696.079916,"in_place_rent_latest_avg":1549.260991},
{"period_start_date":"2024-11-01","asking_rent_latest_avg":1692.148222,"in_place_rent_latest_avg":1549.725204},
{"period_start_date":"2024-10-01","asking_rent_latest_avg":1708.174859,"in_place_rent_latest_avg":1548.708909},
{"period_start_date":"2024-09-01","asking_rent_latest_avg":1732.184978,"in_place_rent_latest_avg":1550.518987},
{"period_start_date":"2024-08-01","asking_rent_latest_avg":1738.118106,"in_place_rent_latest_avg":1531.425354}
]
import json
raw = json.load(open("rent_ts_raw.json"))
raw_sorted = sorted(raw, key=lambda r: r["period_start_date"])
def build(field, name, units):
hist = [{"period": r["period_start_date"], "value": round(r[field], 2), "source": "datamart"} for r in raw_sorted]
check = {"count": len(hist), "sum": round(sum(h["value"] for h in hist), 2), "first_value": hist[0]["value"], "last_value": hist[-1]["value"]}
payload = {
"metric": {"name": name, "units": units, "family": "rent_or_occupancy"},
"subject": {"entity_type": "market", "entity_id": "39580", "label": "Raleigh, NC"},
"horizon": {"years": 3, "intervals": "annual"},
"as_of": "2026-09-11",
"caller_disclosures": {
"sibling_series_note": "Asking and in-place rent run as sibling pair; checked both.",
"peer_omitted_reason": "not gathered"
},
"history": hist,
"history_check": check,
"context_signals": {
"supply_pipeline": {
"existing_stock": 133137,
"under_construction_t12": 7342,
"permitted_units_t13_t24": 6246
},
"migration": {
"inbound_income": 133284,
"outbound_income": 134718
},
"employment": {
"job_growth_1_year_pct": 0.0443
}
},
"scenarios": ["base", "upside", "downside"]
}
return payload
asking = build("asking_rent_latest_avg", "mf_rent_ts.asking_rent_latest_avg (Raleigh MSA)", "$")
inplace = build("in_place_rent_latest_avg", "mf_rent_ts.in_place_rent_latest_avg (Raleigh MSA)", "$")
json.dump(asking, open("payload_asking.json","w"))
json.dump(inplace, open("payload_inplace.json","w"))
print("done")
cd /vercel/sandbox && python3 build_payloads.py && python3 skills/forecasting/scripts/forecast.py --file payload_asking.json > result_asking.json; python3 skills/forecasting/scripts/forecast.py --file payload_inplace.json > result_inplace.json; echo "---ASKING---"; cat result_asking.json | python3 -m json.tool 2>/dev/null || cat result_asking.json; echo "---INPLACE---"; cat result_inplace.json
import json
# Rent & occupancy
asking_latest = 1674.40
inplace_latest = 1541.36
spread_pct = (asking_latest - inplace_latest) / inplace_latest
asking_12mo_ago = 1662.68
inplace_12mo_ago = 1548.67
asking_t12 = (asking_latest - asking_12mo_ago) / asking_12mo_ago
inplace_t12 = (inplace_latest - inplace_12mo_ago) / inplace_12mo_ago
occ_latest = 0.9279
occ_12mo_ago = 0.9485
occ_chg = occ_latest - occ_12mo_ago
# Supply
existing_stock = 133137
uc_units = 7342
uc_pct_stock = uc_units / existing_stock
deliveries_ytd = 1425
net_absorption = 2204
permits_t12 = 6857
permits_t13_t24 = 6246
permits_t12_pct_stock = permits_t12 / existing_stock
permits_trend = (permits_t12 - permits_t13_t24) / permits_t13_t24
sf_permits_t12 = 11846
sf_permits_t13_t24 = 12720
# Tenant ceiling
rent_to_income = 0.2479 # below average nationally -> 0 points for RTI (below 30%)
# Migration cohort
inbound_income_med = 133284
outbound_income_med = 134718
income_diff = inbound_income_med - outbound_income_med
inbound_nw = 4.9
outbound_nw = 6.2
print(json.dumps({
"asking_inplace_spread_pct": round(spread_pct*100,2),
"asking_t12_pct": round(asking_t12*100,2),
"inplace_t12_pct": round(inplace_t12*100,2),
"occupancy_chg_pts": round(occ_chg*100,2),
"uc_pct_of_stock": round(uc_pct_stock*100,2),
"permits_t12_pct_of_stock": round(permits_t12_pct_stock*100,2),
"permits_yoy_trend_pct": round(permits_trend*100,2),
"income_diff_inbound_minus_outbound": income_diff,
"net_worth_tier_gap": round(inbound_nw - outbound_nw,2)
}, indent=2))
cd /vercel/sandbox && python3 calc_metrics.py
cat /vercel/sandbox/skills/docx/references/realai-formatting.md
find /vercel/sandbox/skills/docx -type f; python3 -c "import docx" 2>&1; pip show python-docx 2>&1 | head -5
import json
asking_now = 1674.40
inplace_now = 1541.36
asking = json.load(open("result_asking.json"))
inplace = json.load(open("result_inplace.json"))
def summarize(now, res):
out = {}
for case in ["base_case", "upside_case", "downside_case"]:
yr3 = res[case][2]["value"]
cum = (yr3 - now) / now
out[case] = {"yr1": res[case][0]["value"], "yr3": yr3, "cum_pct_3yr": round(cum*100,2)}
out["confidence"] = res["confidence"]
out["flags"] = res["data_quality_flags"]
return out
print("ASKING:", json.dumps(summarize(asking_now, asking), indent=2))
print("INPLACE:", json.dumps(summarize(inplace_now, inplace), indent=2))
cd /vercel/sandbox && python3 forecast_summary.py
cd /vercel/sandbox && pip show matplotlib 2>&1 | head -3
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
raw = json.load(open("rent_ts_raw.json"))
raw_sorted = sorted(raw, key=lambda r: r["period_start_date"])
dates = [datetime.strptime(r["period_start_date"], "%Y-%m-%d") for r in raw_sorted]
asking = [r["asking_rent_latest_avg"] for r in raw_sorted]
inplace = [r["in_place_rent_latest_avg"] for r in raw_sorted]
ORANGE = "#F4633A"
SLATE = "#5B6B7A"
GRID = "#E4E3DF"
TEXT = "#151513"
LABEL = "#6B6A65"
# Chart 1: historical asking vs in-place
fig, ax = plt.subplots(figsize=(7.2, 3.4), dpi=200)
ax.plot(dates, asking, color=ORANGE, linewidth=2, label="Asking Rent")
ax.plot(dates, inplace, color=SLATE, linewidth=2, label="In-Place Rent")
ax.set_title("Raleigh MF Rent Trend (Monthly Avg, Aug 2024\u2013Aug 2026)", fontsize=11, color=TEXT, loc="left")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_color(GRID)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.tick_params(colors=LABEL, labelsize=8)
ax.yaxis.set_major_formatter(lambda x, pos: f"${x:,.0f}")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
ax.legend(frameon=False, fontsize=9, loc="upper right", labelcolor=TEXT)
fig.tight_layout()
fig.savefig("chart_rent_trend.png", facecolor="white")
plt.close(fig)
# Chart 2: 3-year forecast scenarios (asking rent)
asking_fc = json.load(open("result_asking.json"))
inplace_fc = json.load(open("result_inplace.json"))
years = ["Current", "Year 1", "Year 2", "Year 3"]
def series(now, fc, case):
return [now] + [p["value"] for p in fc[case]]
fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.2), dpi=200, sharey=False)
asking_now = 1674.40
inplace_now = 1541.36
for ax, (title, now, fc) in zip(axes, [("Asking Rent", asking_now, asking_fc), ("In-Place Rent", inplace_now, inplace_fc)]):
ax.plot(years, series(now, fc, "upside_case"), color=SLATE, linewidth=1.6, linestyle="--", label="Upside")
ax.plot(years, series(now, fc, "base_case"), color=ORANGE, linewidth=2.2, label="Base")
ax.plot(years, series(now, fc, "downside_case"), color="#B8923B", linewidth=1.6, linestyle="--", label="Downside")
ax.set_title(title, fontsize=10, color=TEXT, loc="left")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_color(GRID)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.tick_params(colors=LABEL, labelsize=8)
ax.yaxis.set_major_formatter(lambda x, pos: f"${x:,.0f}")
axes[0].legend(frameon=False, fontsize=8, loc="lower left", labelcolor=TEXT)
fig.suptitle("3-Year Rent Forecast Scenarios (RealAI Forecasting Engine)", fontsize=11, color=TEXT, x=0.02, ha="left")
fig.tight_layout(rect=[0, 0, 1, 0.92])
fig.savefig("chart_forecast.png", facecolor="white")
plt.close(fig)
print("charts done")
cd /vercel/sandbox && python3 make_charts.py && ls -la *.png
Now let's build the Word document with python-docx applying the RealAI style spec.
from docx import Document
from docx.shared import Pt, Inches, RGBColor, Twips
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
import copy
TEXT = RGBColor(0x15, 0x15, 0x13)
LABEL = RGBColor(0x6B, 0x6A, 0x65)
ORANGE = RGBColor(0xF4, 0x63, 0x3A)
GREIGE = "FAF9F6"
BORDER = "E4E3DF"
doc = Document()
# Page setup
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1.0)
section.bottom_margin = Inches(1.0)
section.left_margin = Inches(1.125)
section.right_margin = Inches(1.125)
# Base style
normal = doc.styles["Normal"]
normal.font.name = "Source Sans Pro"
normal.font.size = Pt(11)
normal.font.color.rgb = TEXT
normal.paragraph_format.space_after = Pt(7)
normal.paragraph_format.line_spacing = 1.15
def set_heading_style(name, size, color=TEXT, bold=False, space_before=14, space_after=4, font="Georgia"):
st = doc.styles[name]
st.font.name = font
st.font.size = Pt(size)
st.font.color.rgb = color
st.font.bold = bold
st.paragraph_format.space_before = Pt(space_before)
st.paragraph_format.space_after = Pt(space_after)
st.paragraph_format.line_spacing = 1.0
set_heading_style("Title", 28, TEXT, False, 0, 6)
set_heading_style("Heading 1", 20, TEXT, False, 16, 6)
set_heading_style("Heading 2", 15, TEXT, False, 14, 4)
set_heading_style("Heading 3", 12, TEXT, True, 10, 3)
def add_bottom_border(paragraph, color=ORANGE.__str__() if False else "F4633A", sz=6):
pPr = paragraph._p.get_or_add_pPr()
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), str(sz))
bottom.set(qn('w:space'), '2')
bottom.set(qn('w:color'), color)
pBdr.append(bottom)
pPr.append(pBdr)
def h2(text):
p = doc.add_paragraph(text, style="Heading 2")
add_bottom_border(p)
return p
def h3(text):
return doc.add_paragraph(text, style="Heading 3")
def body(text, bold=False, italic=False, color=None, size=None):
p = doc.add_paragraph()
r = p.add_run(text)
r.bold = bold
r.italic = italic
r.font.color.rgb = color or TEXT
r.font.size = Pt(size or 11)
return p
def eyebrow(text):
p = doc.add_paragraph()
r = p.add_run(text.upper())
r.font.size = Pt(9)
r.font.color.rgb = LABEL
r.font.bold = True
p.paragraph_format.space_after = Pt(2)
return p
def caption(text):
p = doc.add_paragraph()
r = p.add_run(text)
r.font.size = Pt(9)
r.font.color.rgb = LABEL
r.font.italic = True
p.paragraph_format.space_after = Pt(10)
return p
def set_cell_border(cell, bottom_color=BORDER, bottom_sz=4, top=False, top_color=None, top_sz=12):
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
tcBorders = OxmlElement('w:tcBorders')
for edge in ["top", "left", "bottom", "right"]:
el = OxmlElement(f'w:{edge}')
el.set(qn('w:val'), 'single')
if edge == "top" and top:
el.set(qn('w:sz'), str(top_sz))
el.set(qn('w:color'), top_color)
else:
el.set(qn('w:sz'), str(bottom_sz))
el.set(qn('w:color'), bottom_color)
tcBorders.append(el)
tcPr.append(tcBorders)
def shade_cell(cell, hex_color):
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), hex_color)
tcPr.append(shd)
def set_cell_text(cell, text, bold=False, size=10, color=TEXT, align=None):
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
pPr = p._p.get_or_add_pPr()
spacing = OxmlElement('w:spacing')
spacing.set(qn('w:line'), '240')
spacing.set(qn('w:lineRule'), 'auto')
pPr.append(spacing)
if align:
p.alignment = align
r = p.add_run(str(text))
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
r.font.name = "Source Sans Pro"
def add_table(headers, rows, col_widths=None, header_fill=GREIGE, numeric_cols=None):
numeric_cols = numeric_cols or []
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = True
# header
for j, htext in enumerate(headers):
cell = table.rows[0].cells[j]
shade_cell(cell, header_fill)
align = WD_ALIGN_PARAGRAPH.RIGHT if j in numeric_cols else WD_ALIGN_PARAGRAPH.LEFT
set_cell_text(cell, htext, bold=True, size=9, color=TEXT, align=align)
set_cell_border(cell, top=True, top_color="F4633A", top_sz=12)
for i, row in enumerate(rows):
for j, val in enumerate(row):
cell = table.rows[i+1].cells[j]
align = WD_ALIGN_PARAGRAPH.RIGHT if j in numeric_cols else WD_ALIGN_PARAGRAPH.LEFT
set_cell_text(cell, val, size=10, align=align)
set_cell_border(cell)
if col_widths:
for row in table.rows:
for j, w in enumerate(col_widths):
row.cells[j].width = Inches(w)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
# ---------------- TITLE PAGE / HEADER ----------------
eyebrow("Multifamily Market Report")
t = doc.add_paragraph("Raleigh, NC", style="Title")
sub = doc.add_paragraph()
r = sub.add_run("Fundamentals, Supply Pipeline, Demand Drivers, and a 3-Year Rent Forecast")
r.font.size = Pt(13)
r.font.color.rgb = LABEL
sub.paragraph_format.space_after = Pt(4)
meta = doc.add_paragraph()
r = meta.add_run("Prepared September 11, 2026 | RealAI Rent Index, RealAI SuperCensus, Cushman & Wakefield MarketBeats, US Census Building Permits Survey, GreenStreet Advisors")
r.font.size = Pt(9)
r.font.color.rgb = LABEL
meta.paragraph_format.space_after = Pt(16)
# ---------------- HEADLINE ----------------
h2("Headline")
body("Raleigh is a demand story running ahead of a supply correction: job growth of 4.4% and income growth of 5.8% over the trailing year are among the strongest in the country, but 7,342 units under construction (5.5% of stock) pushed vacancy to 9.5% and pulled occupancy down 206 basis points, so rents have gone flat-to-negative even as the fundamentals investors care about three years out keep improving.",
italic=False)
# ---------------- SCORECARD ----------------
h2("Market Scorecard")
body("Market posture: Holding.", bold=True)
body("Demand quality is strong and broad-based; the rent softness is a supply-timing problem, not a demand problem \u2014 the pipeline is 12\u201324 months from working through the system.")
add_table(
headers=["Dimension", "Read", "Detail"],
rows=[
["Rent trajectory", "Softening", "Asking rent -1.5% T12; in-place rent -0.9% T12"],
["Tradeout direction", "Improving, near flat", "New-lease tradeout ~0% latest, vs. -5.6% a year ago"],
["Occupancy", "Loosening", "92.8% latest vs. 94.9% 12 months ago (-206 bps)"],
["Supply pipeline", "Heavy", "Under construction = 5.5% of stock; permits T12 up 9.8% y/y"],
["Tenant ceiling", "0 of 3", "Rent-to-income 24.8% \u2014 well under the 30% affordability line"],
["Net migration", "Strong", "+0.6% of population; 76th percentile nationally"],
["Employer base", "Diversified, professional-heavy", "Healthcare 22%; 56% of workers in professional/creative occupations"],
],
col_widths=[1.6, 1.6, 3.05],
)
# ---------------- RENT FUNDAMENTALS ----------------
h2("Rent Fundamentals: Where Rents Are Headed")
body("Asking rent averages $1,674 against an in-place book of $1,541 \u2014 an 8.6% spread that looks like room to grow, but new-lease tradeouts are essentially flat (roughly breakeven in August, versus -5.6% last December), so that spread is not yet capturable pricing power; it is inventory mix, not momentum.")
body("Leasing velocity confirms the same story: days-on-market for signed leases stretched back out to 67 days in August from a 44-day low in June, and occupancy has given up 206 bps over the past year to 92.8%. None of this reads as demand failure \u2014 net absorption of 2,204 units over the past year still outpaced 1,425 units delivered \u2014 but a heavy concurrent pipeline is diluting the occupancy math even as absorption stays positive.")
doc.add_picture("chart_rent_trend.png", width=Inches(6.25))
caption("Source: RealAI Rent Index, monthly market aggregate, Raleigh MSA, Aug 2024\u2013Aug 2026.")
h3("Monthly Rent Detail (Selected Periods)")
add_table(
headers=["Month", "Asking Rent (Avg)", "In-Place Rent (Avg)", "Occupancy", "Days on Market"],
rows=[
["Aug 2025", "$1,667", "$1,549", "95.0%", "59"],
["Nov 2025", "$1,654", "$1,545", "94.6%", "76"],
["Feb 2026", "$1,685", "$1,540", "94.3%", "79"],
["May 2026", "$1,703", "$1,541", "94.3%", "66"],
["Aug 2026", "$1,679", "$1,543", "94.0%", "67"],
],
col_widths=[1.1, 1.35, 1.35, 1.1, 1.35],
numeric_cols=[1,2,3,4],
)
caption("Occupancy shown is trailing-30-day average, which runs slightly above the point-in-time latest figure of 92.8%.")
# ---------------- SUPPLY ----------------
h2("Supply Pipeline")
body("Raleigh's pipeline is the single biggest swing factor for the next 24 months: 7,342 units are under construction, equal to 5.5% of the existing 133,137-unit base, and multifamily permits issued over the trailing 12 months (6,857) are running 9.8% ahead of the prior 12-month period \u2014 both above the levels that typically warn of rent pressure. Vacancy at 9.5% is already elevated versus the roughly 5\u20137% balanced range.")
body("Single-family permitting (11,846 units T12) is larger than multifamily and is a for-sale/BTR signal, not direct rental supply \u2014 it is down 6.9% from the prior 12 months, suggesting the for-sale pipeline is cooling faster than the apartment pipeline.")
add_table(
headers=["Supply Metric", "Value", "Read"],
rows=[
["Existing MF stock", "133,137 units", "\u2014"],
["Under construction", "7,342 units", "5.5% of stock \u2014 heavy"],
["Deliveries (YTD)", "1,425 units", "Below trailing absorption"],
["Net absorption (T12)", "2,204 units", "Outpacing deliveries"],
["MF vacancy rate", "9.5%", "Elevated (>8% threshold)"],
["MF permits (T12)", "6,857 units", "5.1% of stock; +9.8% y/y"],
["MF permits (T13\u2013T24)", "6,246 units", "\u2014"],
["SF permits (T12)", "11,846 units", "-6.9% y/y \u2014 for-sale/BTR, not rental"],
],
col_widths=[2.1, 1.4, 2.75],
)
caption("Sources: Cushman & Wakefield US MarketBeats (supply snapshot, 2Q26 basis) and US Census Building Permits Survey (permit_ts).")
# ---------------- DEMAND ----------------
h2("Demand Drivers: Who's Moving In, and What Anchors It")
body("Raleigh is gaining people and gaining income at once, which is the combination that eventually re-tightens a market once the pipeline clears. Net migration ran +0.6% of population over the sample period \u2014 the 76th percentile nationally \u2014 and median household income grew 5.8% over the trailing 12 months, the 93rd percentile nationally.")
body("The migrant cohort itself is a wash on wealth, not a clear upgrade: inbound households carry a median income of $133,284 against $134,718 for outbound households \u2014 essentially flat, and inbound net worth (tier 4.9 of 11) actually trails outbound (tier 6.2), a modest downgrade signal worth watching even though the population-level income and education base (96th percentile nationally on education) remains among the strongest in the country.")
body("Employment is diversified and skews professional: healthcare is the largest single sector at 22% of workers, manufacturing and retail each near 9%, and 56% of the workforce sits in professional/creative occupations \u2014 the Research Triangle signature. One-year job growth of 4.4% and five-year growth of 21% both outrun the current supply-driven softness in rents.")
add_table(
headers=["Demand Metric", "Value", "Context"],
rows=[
["Population", "1.56M", "+1.4% T12, +17.2% over 5 years"],
["Net migration", "+0.6% of population", "76th percentile nationally"],
["Median household income", "$116,039", "+5.8% T12 \u2014 93rd percentile nationally"],
["Education score (avg)", "7.3 of 10", "96th percentile nationally"],
["Job growth (T12 / 5-yr)", "+4.4% / +21.0%", "Healthcare-led, professional-heavy base"],
["Rent-to-income ratio", "24.8%", "Below the 30% affordability threshold"],
],
col_widths=[2.1, 1.5, 2.65],
)
# ---------------- FORECAST ----------------
h2("3-Year Rent Forecast")
body("The RealAI forecasting engine returns two different confidence reads for the sibling series, and both matter to how this gets used. The in-place (renewal) rent series is high-confidence and projects a base case of +2.6% cumulative over three years \u2014 $1,541 today to $1,581 \u2014 with upside to $1,692 (+9.8%) if absorption keeps outrunning deliveries, and downside to $1,474 (-4.3%) if the pipeline delivers into softer demand.")
body("The asking (new-lease) rent series carries a low-confidence flag: with only 25 months of history and a negative trailing trend, the engine's cyclical-drawdown guard held the base case flat at $1,656 after year one rather than extrapolating the current correction \u2014 the engine's own instruction is not to use that flat line as a multi-year assumption without analyst judgment layered on top.", italic=False)
body("Analyst judgment on asking rent: with net absorption already outpacing deliveries, tradeouts recovering from -5.6% to roughly flat over the past eight months, and 6,857 permitted units representing the leading edge rather than a growing backlog, the more defensible three-year path for asking rent sits between the engine's base and upside cases \u2014 flat through the pipeline's peak delivery window (roughly the next 12\u201318 months) and then resuming growth as deliveries roll off against still-strong job and income growth. Underwrite the engine's base case as the floor and its upside case (+6.0% cumulative, to $1,774) as the case if the pipeline clears on schedule.", bold=False)
doc.add_picture("chart_forecast.png", width=Inches(6.5))
caption("Source: RealAI Forecasting Engine (forecast.py), rent_or_occupancy family, dampened trend with structural terminal anchor. Asking rent confidence: low (cyclical drawdown guard fired). In-place rent confidence: high.")
add_table(
headers=["Rent Series", "Current", "Year 1 (Base)", "Year 3 (Base)", "Year 3 (Upside)", "Year 3 (Downside)"],
rows=[
["Asking rent", "$1,674", "$1,656 (-1.3%)", "$1,656 (-1.1% cum.)", "$1,774 (+6.0% cum.)", "$1,543 (-7.8% cum.)"],
["In-place rent", "$1,541", "$1,541 (-0.1%)", "$1,581 (+2.6% cum.)", "$1,692 (+9.8% cum.)", "$1,474 (-4.3% cum.)"],
],
col_widths=[1.3, 0.85, 1.15, 1.2, 1.2, 1.2],
numeric_cols=[1,2,3,4,5],
)
caption("Cumulative % changes are measured from the current latest reading to the Year 3 projection. Both series carry a dampening-cap flag (far-out years weakly informative) and a thin-peer-cohort flag (no market peer series was supplied, so mean reversion was not applied).")
# ---------------- RISKS ----------------
h2("Risks and Follow-Ups")
body("Pipeline overhang: if the 7,342 units under construction deliver faster than the current 12\u201324-month pace assumes, or if a second wave of permits (6,857 T12, still accelerating) breaks ground before the current wave leases up, vacancy could push through 10% and the downside case becomes the base case.", bold=False)
h3("What would change the read")
body("Two consecutive months of positive tradeouts alongside a decelerating permit trend (T12 falling below T13\u2013T24) would confirm the pipeline is clearing and support moving toward the upside case ahead of schedule. Conversely, occupancy breaking below 92% would signal the correction is deeper than the data currently shows.", bold=False)
# ---------------- CLOSE ----------------
h2("Bottom Line")
body("Underwrite Raleigh on the in-place, high-confidence rent path (+2.6% base, 3-year), treat asking rent as flat-to-recovering rather than declining, and size any near-term rent-growth assumption to the supply clock \u2014 not the demand story, which is not the constraint here.", bold=True)
body("The one datapoint that would flip this from Holding to Weakening: MF vacancy crossing 10% before deliveries YTD (1,425) close the gap with under-construction supply (7,342) \u2014 that would mean the pipeline is landing into demand that isn't strong enough to absorb it.")
doc.save("Raleigh_MF_Market_Report.docx")
print("saved")
cd /vercel/sandbox && python3 build_report.py
Let's verify it opens correctly and check pages via conversion, then save/present it and register citations.
cd /vercel/sandbox && libreoffice --headless --convert-to pdf Raleigh_MF_Market_Report.docx --outdir preview 2>&1 | tail -5 && ls preview
Good, it converts cleanly. Now let's save and present the docx, plus save the preview PDF for citation purposes.
cd /vercel/sandbox && python3 -c "
import pdfplumber
with pdfplumber.open('preview/Raleigh_MF_Market_Report.pdf') as pdf:
for i, page in enumerate(pdf.pages, start=1):
text = page.extract_text() or ''
print('PAGE', i, '---', text[:120].replace(chr(10),' | '))
"
Good — page mapping established. Now presenting the file and writing the chat response with citations.
Raleigh's rent softness is a supply-timing problem, not a demand problem — and the data says it clears within 24 months
The Word report is attached above. It walks through fundamentals, the supply pipeline, demand drivers, and a 3-year rent forecast for the Raleigh, NC MSA — built to hand to investors as-is.
The core tension driving the report: job growth of 4.4% and income growth of 5.8% over the trailing year rank among the strongest in the country , but 7,342 units under construction (5.5% of the existing 133,137-unit base) and multifamily permits running 9.8% ahead of the prior year have pushed vacancy to 9.5% and pulled occupancy down 206 bps — so both asking and in-place rent are flat-to-negative on a trailing 12-month basis even as the underlying demand case keeps improving.
Two things worth flagging directly since they shape how you should use the forecast section:
One thing worth watching that the data surfaces but doesn't scream: the migrant cohort's net worth is a slight downgrade — inbound households run wealth tier 4.9 versus 6.2 for outbound, even though incomes are essentially flat between the two groups . Not disqualifying, but worth tracking if it persists.
I kept explicit peer-market comparisons out of this cut and leaned on national percentile context instead (Raleigh sits at the 76th percentile on migration, 93rd on income growth, 96th on education) — happy to add a peer-market scorecard (Nashville, Charlotte, Austin) if that comparison matters for your decision.
This is a read-only snapshot of a RealAI analysis.