Write a quarterly LP update letter for a value-add multifamily fund with assets in Atlanta, Dallas, and Phoenix, covering market conditions in each metro, portfolio outlook, and the rate environment
cat << 'EOF' > /vercel/sandbox/supply_pipeline_ratios.py
# Supply pipeline pressure: under-construction units as a multiple of YTD net absorption,
# and permit deceleration (T12 vs T13-T24) by metro.
markets = {
"Atlanta": {"under_construction": 13609, "deliveries_ytd": 7104, "absorption_ytd": 6230,
"vacancy": 0.112, "permits_t12": 12042, "permits_t13_t24": 11551,
"cap_rate_2q26": 0.0533, "cap_rate_1q25": 0.0534},
"Dallas": {"under_construction": 30321, "deliveries_ytd": 15375, "absorption_ytd": 10146,
"vacancy": 0.119, "permits_t12": 23605, "permits_t13_t24": 25675,
"cap_rate_2q26": 0.0519, "cap_rate_1q25": 0.0520},
"Phoenix": {"under_construction": 16457, "deliveries_ytd": 8454, "absorption_ytd": 6032,
"vacancy": 0.116, "permits_t12": 12626, "permits_t13_t24": 12753,
"cap_rate_2q25": 0.0509, "cap_rate_2q26": 0.0525},
}
for m, d in markets.items():
pipeline_multiple = d["under_construction"] / d["absorption_ytd"]
deliveries_vs_absorption = d["deliveries_ytd"] / d["absorption_ytd"]
permit_yoy_chg = (d["permits_t12"] - d["permits_t13_t24"]) / d["permits_t13_t24"] * 100
print(f"{m}: pipeline/absorption multiple = {pipeline_multiple:.1f}x | "
f"deliveries/absorption = {deliveries_vs_absorption:.2f}x | "
f"permit YoY chg = {permit_yoy_chg:+.1f}%")
print()
print("Cap rate moves (multifamily, GreenStreet):")
print(f"Atlanta: 1Q25 {markets['Atlanta']['cap_rate_1q25']*100:.2f}% -> 2Q26 {markets['Atlanta']['cap_rate_2q26']*100:.2f}% ({(markets['Atlanta']['cap_rate_2q26']-markets['Atlanta']['cap_rate_1q25'])*10000:+.0f} bps)")
print(f"Dallas: 1Q25 {markets['Dallas']['cap_rate_1q25']*100:.2f}% -> 2Q26 {markets['Dallas']['cap_rate_2q26']*100:.2f}% ({(markets['Dallas']['cap_rate_2q26']-markets['Dallas']['cap_rate_1q25'])*10000:+.0f} bps)")
print(f"Phoenix: 2Q25 {markets['Phoenix']['cap_rate_2q25']*100:.2f}% -> 2Q26 {markets['Phoenix']['cap_rate_2q26']*100:.2f}% ({(markets['Phoenix']['cap_rate_2q26']-markets['Phoenix']['cap_rate_2q25'])*10000:+.0f} bps)")
# SOFR forward drift
sofr_aug26 = 0.0364
sofr_jul27 = 0.0413
print()
print(f"SOFR 1m forward curve: Aug'26 {sofr_aug26*100:.2f}% -> Jul'27 {sofr_jul27*100:.2f}% ({(sofr_jul27-sofr_aug26)*10000:+.0f} bps over 11 months)")
EOF
python3 /vercel/sandbox/supply_pipeline_ratios.pyfind /vercel/sandbox/skills/docx -type f
pip show python-docx >/dev/null 2>&1 && echo ok || pip install python-docx -q
"""Build the quarterly LP update letter as a formatted .docx per RealAI default style."""
from docx import Document
from docx.shared import Pt, Inches, Twips, RGBColor
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
# ---------- palette ----------
INK = RGBColor(0x15, 0x15, 0x13)
TERTIARY = RGBColor(0x6B, 0x6A, 0x65)
ACCENT = RGBColor(0xF4, 0x63, 0x3A)
GREIGE = "FAF9F6"
BORDER = "E4E3DF"
SERIF = "Georgia" # Ivar fallback
SANS = "Calibri" # Source Sans 3 fallback
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)
styles = doc.styles
def set_run_font(run, name=SANS, size=11, color=INK, bold=False, italic=False):
run.font.name = name
run.font.size = Pt(size)
run.font.color.rgb = color
run.font.bold = bold
run.font.italic = italic
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.append(rFonts)
rFonts.set(qn('w:ascii'), name)
rFonts.set(qn('w:hAnsi'), name)
rFonts.set(qn('w:eastAsia'), name)
def set_paragraph_spacing(p, before=0, after=7, line=276, line_rule='auto'):
pf = p.paragraph_format
pf.space_before = Pt(before)
pf.space_after = Pt(after)
pf.line_spacing_rule = None
pPr = p._p.get_or_add_pPr()
spacing = pPr.find(qn('w:spacing'))
if spacing is None:
spacing = OxmlElement('w:spacing')
pPr.append(spacing)
spacing.set(qn('w:before'), str(int(before * 20)))
spacing.set(qn('w:after'), str(int(after * 20)))
spacing.set(qn('w:line'), str(line))
spacing.set(qn('w:lineRule'), line_rule)
def add_title(text):
p = doc.add_paragraph()
set_paragraph_spacing(p, before=0, after=6)
r = p.add_run(text)
set_run_font(r, name=SERIF, size=24, color=INK, bold=True)
return p
def add_h2(text):
p = doc.add_paragraph()
set_paragraph_spacing(p, before=14, after=4)
r = p.add_run(text)
set_run_font(r, name=SERIF, size=15, color=INK, bold=True)
# bottom border, orange, 0.75pt
pPr = p._p.get_or_add_pPr()
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), '6')
bottom.set(qn('w:space'), '2')
bottom.set(qn('w:color'), 'F4633A')
pBdr.append(bottom)
pPr.append(pBdr)
return p
def add_h3(text):
p = doc.add_paragraph()
set_paragraph_spacing(p, before=10, after=3)
r = p.add_run(text)
set_run_font(r, name=SERIF, size=12, color=INK, bold=True)
return p
def add_body(text, bold=False, italic=False, size=11, color=INK):
p = doc.add_paragraph()
set_paragraph_spacing(p, before=0, after=7)
r = p.add_run(text)
set_run_font(r, name=SANS, size=size, color=color, bold=bold, italic=italic)
return p
def add_disclaimer(text):
p = doc.add_paragraph()
set_paragraph_spacing(p, before=10, after=0)
r = p.add_run(text)
set_run_font(r, name=SANS, size=9, color=TERTIARY, italic=True)
return p
def set_cell_shading(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_border(cell, color=BORDER, sz=4):
tcPr = cell._tc.get_or_add_tcPr()
borders = OxmlElement('w:tcBorders')
for edge in ('top', 'left', 'bottom', 'right'):
el = OxmlElement(f'w:{edge}')
el.set(qn('w:val'), 'single')
el.set(qn('w:sz'), str(sz))
el.set(qn('w:color'), color)
borders.append(el)
tcPr.append(borders)
def set_cell_text(cell, text, bold=False, size=10, color=INK, align='left'):
cell.text = ''
p = cell.paragraphs[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 == 'right':
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
r = p.add_run(text)
set_run_font(r, name=SANS, size=size, color=color, bold=bold)
set_cell_border(cell)
def add_table(headers, rows, col_align=None):
table = doc.add_table(rows=1, cols=len(headers))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = True
hdr_cells = table.rows[0].cells
for i, h in enumerate(headers):
set_cell_text(hdr_cells[i], h, bold=True, size=9, color=INK,
align='right' if (col_align and col_align[i] == 'r') else 'left')
set_cell_shading(hdr_cells[i], GREIGE)
for row in rows:
cells = table.add_row().cells
for i, val in enumerate(row):
cells[i].text = ''
set_cell_text(cells[i], val, size=10, color=INK,
align='right' if (col_align and col_align[i] == 'r') else 'left')
return table
# ================= CONTENT =================
add_title("Sun Belt Value-Add Multifamily Fund")
p = doc.add_paragraph()
set_paragraph_spacing(p, before=0, after=14)
r = p.add_run("Quarterly Letter to Limited Partners — Q2 2026")
set_run_font(r, name=SANS, size=12, color=TERTIARY, italic=True)
add_body("Dear Limited Partners,")
add_body(
"The story across our three markets this quarter is the same story with three different endings: "
"supply is still the dominant force in Atlanta, Dallas, and Phoenix, but it is starting to clear at "
"different speeds. Atlanta's pipeline is the closest to worked through, Dallas remains the most "
"supply-burdened market in the portfolio, and Phoenix is the one market where cap rates have moved "
"against us. None of the three markets is producing organic rent growth right now — that has to come "
"from unit-level execution, not the market, and that is where this letter's portfolio section focuses."
)
# ---------------- Market Conditions ----------------
add_h2("Market Conditions")
add_body(
"The table below summarizes current multifamily fundamentals in each metro. Rent and occupancy figures "
"reflect RealAI Rent Index data as of September 5, 2026 [1](#endnote-1); vacancy, deliveries, absorption, "
"and construction pipeline reflect Cushman & Wakefield MarketBeat data as of the second quarter of 2026 "
"[2](#endnote-2); cap rates reflect GreenStreet Advisors institutional multifamily cap rates for the same "
"quarter [3](#endnote-3)."
)
headers = ["Metric", "Atlanta", "Dallas–Fort Worth", "Phoenix"]
rows = [
["Asking rent (avg)", "$1,784", "$1,620", "$1,678"],
["Asking rent, 12-mo chg", "-1.7%", "-1.0%", "-2.0%"],
["In-place rent (avg)", "$1,662", "$1,551", "$1,597"],
["Physical occupancy", "93.1%", "92.1%", "92.9%"],
["Occupancy, 12-mo chg", "-2.4 pts", "-3.3 pts", "-2.2 pts"],
["New-lease trade-out", "+0.3%", "-1.2%", "-3.0%"],
["Market vacancy rate", "11.2%", "11.9%", "11.6%"],
["Units under construction", "13,609", "30,321", "16,457"],
["Pipeline vs. YTD absorption", "2.2x", "3.0x", "2.7x"],
["MF permits, T12 vs. T13-24", "+4.3%", "-8.1%", "-1.0%"],
["Multifamily cap rate (2Q26)", "5.33%", "5.19%", "5.25%"],
["Cap rate, 4-qtr move", "-1 bps", "-1 bps", "+16 bps"],
["NOI margin (% of EGI)", "49.7%", "45.8%", "60.1%"],
]
add_table(headers, rows, col_align=['l', 'r', 'r', 'r'])
add_h3("Atlanta")
add_body(
"Atlanta is the closest of the three to a supply trough. Under-construction inventory of 13,609 units "
"runs about 2.2x trailing net absorption — still elevated, but the shallowest overhang in the portfolio "
"[4](#endnote-4), and new-lease trade-outs are marginally positive (+0.3%), meaning renewing tenants are "
"not yet being asked to pay up but new leases are not underwater either [5](#endnote-5). Cap rates have "
"been flat at ~5.33% for a year, which tells us Atlanta's investor base is treating the softness as "
"cyclical, not structural [6](#endnote-6). Occupancy is still down 2.4 points year-over-year, so the "
"supply digestion is not finished, but this is the market where we expect rent growth to turn positive first."
)
add_h3("Dallas–Fort Worth")
add_body(
"Dallas is carrying the heaviest supply load in the portfolio by a wide margin: 30,321 units under "
"construction against 10,146 units of trailing net absorption, a 3.0x overhang, with deliveries YTD "
"(15,375 units) already exceeding a full year of absorption [7](#endnote-7). Occupancy has fallen the "
"furthest of the three metros (-3.3 points year-over-year) and new leases are trading down 1.2% "
"[8](#endnote-8). The one genuine positive: multifamily permitting is down 8.1% year-over-year versus "
"the prior 12-month period, the sharpest pullback of the three markets, which should let the pipeline "
"burn off through 2027 rather than reload it [9](#endnote-9). Cap rates have held near 5.19% throughout, "
"so pricing has not (yet) repriced for the extra supply — we treat that as a risk to underwrite around, "
"not a reason to relax. NOI margins here are also the thinnest in the portfolio (45.8% of EGI), reflecting "
"both concession pressure on revenue and a higher relative expense load [10](#endnote-10)."
)
add_h3("Phoenix")
add_body(
"Phoenix is the outlier: it is the only one of the three markets where cap rates have actually moved "
"against us, widening 16 basis points over the past year to 5.25%, even as its supply pipeline (2.7x "
"absorption) sits between Atlanta's and Dallas's [11](#endnote-11). Rent trends are also the softest — "
"asking rents are down 2.0% year-over-year and new leases are trading down nearly 3%, the steepest "
"concession pressure in the portfolio [12](#endnote-12). The offsetting fact, and the reason we are not "
"marking this market down further: permitting has essentially stalled (-1.0% year-over-year) and NOI "
"margins remain the strongest of the three metros at 60.1% of EGI, evidence that Phoenix's lower relative "
"expense structure is still doing real work for ownership even while topline rent is under pressure "
"[13](#endnote-13)."
)
# ---------------- Portfolio Outlook ----------------
add_h2("Portfolio Outlook")
add_body(
"Fund-level occupancy, releasing spreads, and renovation ROI at [PROPERTY NAME(S)] are reported in the "
"attached asset-level summaries; the framework below is how we are underwriting the next two to three "
"quarters against the market backdrop above.", italic=True
)
add_body(
"Atlanta assets: with the pipeline closest to clearing and trade-outs already flat-to-positive, this is "
"where we expect renovation premiums to hold up best on turn. We are prioritizing unit turns at "
"[ASSET NAME] over the next two quarters and expect the renovation-to-classic rent spread to widen as "
"concessions in the surrounding submarket fade."
)
add_body(
"Dallas assets: we are underwriting flat-to-negative blended rent growth through at least mid-2027 given "
"the 3.0x pipeline overhang, and we are holding back non-essential capex at [ASSET NAME] until absorption "
"data confirms the market has turned. Retention and expense control — not rent growth — are this asset's "
"value-add levers for the next several quarters."
)
add_body(
"Phoenix assets: the cap rate widening is the one market signal we are watching most closely for "
"disposition timing — a further 15-25 basis points of expansion would meaningfully compress our exit "
"underwriting at [ASSET NAME]. Operationally, we continue to lean on this market's structurally lower "
"expense ratio to protect NOI while rents are soft, and we are not pushing loss-to-lease capture until "
"trade-outs stabilize."
)
add_body(
"Across the portfolio, we are not underwriting a rent-growth recovery before mid-to-late 2027 in any of "
"the three markets. Where we can still create value in this window is expense management, renewal "
"retention, and disciplined capex sequencing — precisely the value-add plan this Fund was built to execute "
"in a slow market, not a hot one."
)
# ---------------- Rate Environment ----------------
add_h2("The Rate Environment")
add_body(
"The Fed funds rate stood at 3.63% in August 2026, with 30-year fixed mortgage rates at 6.67% and "
"15-year fixed at 5.98% [14](#endnote-14). More relevant to the Fund's floating-rate debt and near-term "
"refinancings, the forward SOFR curve is drifting up, not down: 1-month SOFR is projected to rise from "
"3.64% in August 2026 to 4.13% by July 2027, a 49 basis-point increase, as the curve currently prices in "
"no further near-term easing [15](#endnote-15). We are not underwriting rate relief into any of our "
"refinancing or exit assumptions."
)
add_body(
"On the permanent debt side, agency execution remains the cheapest source of capital for the Fund's "
"stabilizing assets: Fannie Mae and Freddie Mac 10-year fixed-rate quotes are running 5.8%-6.2% at "
"55%-65% leverage, versus 6.2%-6.9% for life company debt and 6.6%-7.1% for CMBS execution at comparable "
"terms [16](#endnote-16). That roughly 60-90 basis-point agency advantage is why we continue to route "
"refinancings through Fannie/Freddie wherever loan size and asset quality qualify, and why we are being "
"selective about which assets we bring to the CMBS or bridge market this cycle."
)
add_body(
"Net effect for the Fund: debt costs on new originations are higher than they were 90 days ago, not "
"lower, and the forward curve suggests that will remain the case through mid-2027. Combined with the "
"cap rate picture above — flat in Atlanta and Dallas, widening in Phoenix — we are underwriting exits "
"conservatively and are prepared to hold assets through a refinancing rather than a sale if pricing in a "
"given market does not clear our return hurdles."
)
# ---------------- Closing ----------------
add_h2("Closing")
add_body(
"We remain confident in the long-term thesis for all three markets — each continues to add jobs and "
"households at a pace that will eventually clear the current supply wave — but we are underwriting the "
"next several quarters as a hold-and-execute period, not a growth period. Thank you for your continued "
"partnership. As always, we are available for any questions on the portfolio or this letter."
)
add_body("Sincerely,")
add_body("[General Partner / Fund Manager Name]", bold=True)
add_disclaimer(
"This letter is for informational purposes only and does not constitute investment advice or an offer to "
"sell or a solicitation of an offer to buy any security. Market data sourced from RealAI Rent Index, "
"Cushman & Wakefield US MarketBeats, GreenStreet Advisors, and the Federal Reserve as cited. Bracketed "
"items require completion with Fund-specific asset data prior to distribution."
)
doc.save("LP_Quarterly_Letter_Q2_2026.docx")
print("saved")
cd /vercel/sandbox && python3 - << 'EOF'
import re
with open('build_letter.py') as f:
content = f.read()
# remove markdown citation markers like " [1](#endnote-1)" etc. within triple-quoted strings
content = re.sub(r'\s*\[\d+\]\(#endnote-\d+\)', '', content)
with open('build_letter.py', 'w') as f:
f.write(content)
print("done")
EOF
grep -o "endnote" build_letter.py | wc -lNow let me run the script and fix any remaining double-space artifacts from removal.
cd /vercel/sandbox && grep -n "\.$\|,$" build_letter.py | grep -i "\. \|, " ; python3 build_letter.py
Now let's verify text integrity and check for double spaces left from citation removal.
cd /vercel/sandbox && python3 - << 'EOF'
from docx import Document
d = Document("LP_Quarterly_Letter_Q2_2026.docx")
for p in d.paragraphs:
if ' ' in p.text and p.text.strip():
print(repr(p.text[:200]))
EOFThat one's fine (existing double-space before "NOI margins" sentence). Let me check it's not orphaned punctuation.
cd /vercel/sandbox && python3 - << 'EOF'
from docx import Document
d = Document("LP_Quarterly_Letter_Q2_2026.docx")
for p in d.paragraphs:
if 'NOI margins here' in p.text:
print(repr(p.text))
EOFcd /vercel/sandbox && sed -i 's/trading down 1.2% \. /trading down 1.2%. /' build_letter.py sed -i 's/relax\. NOI margins/relax. NOI margins/' build_letter.py grep -n "% \. \|% \|\. [A-Z]" build_letter.py
Let me check for other similar artifacts across the whole file before regenerating.
cd /vercel/sandbox && grep -n '% \.' build_letter.py; grep -n '%\.\.' build_letter.py; python3 - << 'EOF'
import re
with open('build_letter.py') as f:
c = f.read()
for m in re.finditer(r'.{20}\s\.\s.{20}', c):
print(repr(m.group()))
EOFClean. Regenerate the doc.
cd /vercel/sandbox && python3 build_letter.py && ls -la LP_Quarterly_Letter_Q2_2026.docx
Now save and present the file to the user.
Same supply story, three different clocks — Atlanta is closest to clearing, Dallas is the most overhung, and Phoenix is the only market where cap rates have moved against you
Drafted the Q2 2026 LP letter for a three-metro (Atlanta/Dallas/Phoenix) value-add fund, grounded in current market data with bracketed placeholders where fund-specific asset performance (occupancy, releasing spreads, renovation ROI at named properties) needs to be dropped in — I don't have your actual portfolio data, so I didn't fabricate it.
The read across the three markets:
On rates: the forward SOFR curve is drifting up, not down — 1-month SOFR is projected to move from 3.64% to 4.13% by July 2027 — so I built the letter around "no rate relief," not the reverse . Agency debt (Fannie/Freddie, 5.8–6.2% at 55–65% LTV) still prices 60–90 bps inside life co and CMBS execution, which is the financing lever the letter leans on for refis .
The document is formatted to RealAI house style and ready to have your fund name, GP signature, and asset-level performance dropped into the bracketed fields before it goes out.
The letter is saved to your library above — ready to drop in your fund name, GP signature, and asset-level performance where bracketed. Happy to go deeper on any single metro (permit-level detail, submarket breakouts) or build out the debt schedule for specific loans coming due if that's useful for this letter.
This is a read-only snapshot of a RealAI analysis.