Create a property and market report ppt deck for Palm Valley Villas in AZ and show me all the trends
Now let me pull the core property, submarket, and market data needed for the trends deck.
Now let's pull time-series data for trend charts: property rent/occupancy history, market cap rate history, and submarket rent history.
Now the correct field names.
mkdir -p /vercel/sandbox/deck && python3 -c "import pptx" 2>&1 | tail -5; pip show python-pptx 2>&1 | head -3; pip install python-pptx matplotlib --quiet 2>&1 | tail -5
Now let's build the chart images and the deck.
import json
property_ts = [
("2024-09","1932.33","1741.16",0.920),
("2024-10","1949.00","1737.84",0.952),
("2024-11","1829.71","1741.42",0.960),
("2024-12","1699.00","1741.36",0.944),
("2025-01","1803.17","1733.25",0.960),
("2025-02","1762.85","1730.62",0.976),
("2025-03","1741.86","1736.40",0.944),
("2025-04","1719.83","1739.22",0.968),
("2025-05","1875.67","1733.17",0.952),
("2025-06","1806.14","1735.03",0.936),
("2025-07","1882.33","1728.54",0.936),
("2025-08","1733.78","1728.04",0.952),
("2025-09","1697.67","1710.44",0.960),
("2025-10","1764.00","1711.81",0.960),
("2025-11","1829.00","1702.55",0.944),
("2025-12","1941.86","1700.88",0.952),
("2026-01","1941.73","1702.61",0.936),
("2026-02","1906.67","1702.64",0.936),
("2026-03","1894.42","1708.59",0.952),
("2026-04","1906.69","1702.51",0.952),
("2026-05","1785.67","1702.09",0.952),
("2026-06","1799.00","1704.64",0.952),
("2026-07","1775.54","1698.38",0.960),
("2026-08","1808.09","1692.07",0.936),
]
submarket_ts = [
("2024-09","1708.97",0.938),
("2024-10","1690.84",0.941),
("2024-11","1688.22",0.947),
("2024-12","1680.88",0.941),
("2025-01","1680.64",0.952),
("2025-02","1670.87",0.955),
("2025-03","1664.84",0.804),
("2025-04","1661.55",0.906),
("2025-05","1750.24",0.901),
("2025-06","1754.05",0.912),
("2025-07","1759.10",0.906),
("2025-08","1764.34",0.917),
("2025-09","1762.45",0.901),
("2025-10","1758.62",0.910),
("2025-11","1762.67","0.904"),
("2025-12","1760.28",0.900),
("2026-01","1760.25",0.892),
("2026-02","1755.86",0.904),
("2026-03","1743.92",0.906),
("2026-04","1687.48",0.905),
("2026-05","1688.89",0.894),
("2026-06","1673.81",0.908),
("2026-07","1650.18",0.915),
("2026-08","1639.27",0.905),
]
caprate_ts = [
("3Q21",0.0365),("4Q21",0.0352),("1Q22",0.0367),("2Q22",0.0398),("3Q22",0.0407),
("4Q22",0.0485),("1Q23",0.0501),("2Q23",0.0501),("3Q23",0.0565),("4Q23",0.0524),
("1Q24",0.0524),("2Q24",0.0524),("3Q24",0.0509),("4Q24",0.0488),("1Q25",0.0509),
("2Q25",0.0535),("3Q25",0.0535),("4Q25",0.0535),("1Q26",0.0525),("2Q26",0.0525),
]
with open("deck/data.json","w") as f:
json.dump({"property_ts":property_ts,"submarket_ts":submarket_ts,"caprate_ts":caprate_ts}, f)
print("saved")
cd /vercel/sandbox/deck && python3 data.py
cd /vercel/sandbox && sed -i 's#deck/data.json#data.json#' deck/data.py && cd deck && python3 data.py && ls
Now build the chart generation script (rent trend, occupancy trend, cap rate trend, opex breakdown) and then the deck itself.
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
with open("data.json") as f:
d = json.load(f)
ORANGE = "#F4633A"
NEARBLACK = "#151513"
GREIGE = "#FAF9F6"
SLATE = "#5B7A99"
GRAY = "#9C9A93"
plt.rcParams.update({
"font.family": "DejaVu Sans",
"text.color": NEARBLACK,
"axes.edgecolor": "#E4E3DF",
"axes.labelcolor": NEARBLACK,
"xtick.color": NEARBLACK,
"ytick.color": NEARBLACK,
"axes.facecolor": "#FFFFFF",
"figure.facecolor": "#FFFFFF",
"font.size": 12,
})
def parse_dates(rows):
return [datetime.strptime(r[0], "%Y-%m") for r in rows]
# --- Chart 1: In-place rent trend, property vs submarket ---
prop = d["property_ts"]
sub = d["submarket_ts"]
dates_p = parse_dates(prop)
dates_s = parse_dates(sub)
prop_inplace = [float(r[2]) for r in prop]
sub_inplace = [float(r[1]) for r in sub]
fig, ax = plt.subplots(figsize=(10, 4.2), dpi=200)
ax.plot(dates_p, prop_inplace, color=ORANGE, linewidth=2.5, label="Palm Valley Villas (in-place)")
ax.plot(dates_s, sub_inplace, color=SLATE, linewidth=2, linestyle="--", label="Litchfield submarket (in-place)")
ax.set_ylabel("In-place rent ($/mo)")
ax.spines[["top", "right"]].set_visible(False)
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
ax.legend(frameon=False, loc="lower left")
ax.grid(axis="y", color="#E4E3DF", linewidth=0.7)
plt.tight_layout()
plt.savefig("chart_rent_trend.png", transparent=False)
plt.close()
# --- Chart 2: Occupancy trend, property vs submarket ---
prop_occ = [float(r[3]) * 100 for r in prop]
sub_occ = [float(r[2]) * 100 for r in sub]
fig, ax = plt.subplots(figsize=(10, 4.2), dpi=200)
ax.plot(dates_p, prop_occ, color=ORANGE, linewidth=2.5, label="Palm Valley Villas")
ax.plot(dates_s, sub_occ, color=SLATE, linewidth=2, linestyle="--", label="Litchfield submarket")
ax.set_ylabel("Physical occupancy (%)")
ax.spines[["top", "right"]].set_visible(False)
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
ax.legend(frameon=False, loc="lower left")
ax.grid(axis="y", color="#E4E3DF", linewidth=0.7)
plt.tight_layout()
plt.savefig("chart_occupancy_trend.png", transparent=False)
plt.close()
# --- Chart 3: Asking vs in-place rent, property only ---
prop_asking = [float(r[1]) for r in prop]
fig, ax = plt.subplots(figsize=(10, 4.2), dpi=200)
ax.plot(dates_p, prop_asking, color=NEARBLACK, linewidth=2, label="Asking rent")
ax.plot(dates_p, prop_inplace, color=ORANGE, linewidth=2.5, label="In-place rent")
ax.set_ylabel("Rent per unit ($/mo)")
ax.spines[["top", "right"]].set_visible(False)
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
ax.legend(frameon=False, loc="lower left")
ax.grid(axis="y", color="#E4E3DF", linewidth=0.7)
plt.tight_layout()
plt.savefig("chart_asking_vs_inplace.png", transparent=False)
plt.close()
# --- Chart 4: Phoenix MF cap rate trend ---
cap = d["caprate_ts"]
labels = [r[0] for r in cap]
vals = [float(r[1]) * 100 for r in cap]
fig, ax = plt.subplots(figsize=(10, 4.2), dpi=200)
ax.plot(range(len(labels)), vals, color=ORANGE, linewidth=2.5, marker="o", markersize=4)
ax.set_ylabel("Multifamily cap rate (%)")
ax.spines[["top", "right"]].set_visible(False)
ax.set_xticks(range(0, len(labels), 2))
ax.set_xticklabels([labels[i] for i in range(0, len(labels), 2)], rotation=0)
ax.grid(axis="y", color="#E4E3DF", linewidth=0.7)
plt.tight_layout()
plt.savefig("chart_caprate_trend.png", transparent=False)
plt.close()
# --- Chart 5: Opex breakdown pie ---
opex_labels = ["Payroll & benefits", "Utilities", "R&M", "Mgmt fees", "G&A", "Insurance", "Advertising", "Property tax"]
opex_vals = [267571.39, 153033.58, 89314.42, 74354.79, 49537.5, 38563.18, 44129.26, 177580.62]
colors = [ORANGE, SLATE, "#D9A441", GRAY, "#8C8A83", "#B4CBD6", "#E0A98F", NEARBLACK]
fig, ax = plt.subplots(figsize=(7, 5.5), dpi=200)
wedges, texts, autotexts = ax.pie(
opex_vals, labels=opex_labels, autopct="%1.0f%%", colors=colors,
textprops={"fontsize": 10}, pctdistance=0.75, startangle=90
)
for at in autotexts:
at.set_color("white")
at.set_fontsize(9)
plt.tight_layout()
plt.savefig("chart_opex_breakdown.png", transparent=False)
plt.close()
print("charts done")
cd /vercel/sandbox/deck && python3 make_charts.py && ls -la *.png
Now let's build the PowerPoint deck itself.
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn
# Brand colors
ORANGE = RGBColor(0xF4, 0x63, 0x3A)
NEARBLACK = RGBColor(0x15, 0x15, 0x13)
GREIGE = RGBColor(0xFA, 0xF9, 0xF6)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
GRAY = RGBColor(0x9C, 0x9A, 0x93)
BORDER = RGBColor(0xE4, 0xE3, 0xDF)
SLATE = RGBColor(0x5B, 0x7A, 0x99)
SANS = "Source Sans Pro"
SERIF = "Georgia" # Ivar not available; Georgia approximates the serif editorial feel
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
def add_slide():
return prs.slides.add_slide(blank)
def set_bg(slide, color=WHITE):
bg = slide.background
bg.fill.solid()
bg.fill.fore_color.rgb = color
def add_rect(slide, x, y, w, h, color, line=False):
shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
shp.fill.solid()
shp.fill.fore_color.rgb = color
if not line:
shp.line.fill.background()
else:
shp.line.color.rgb = BORDER
shp.line.width = Pt(0.75)
shp.shadow.inherit = False
return shp
def add_text(slide, x, y, w, h, text, size=14, color=NEARBLACK, bold=False, font=SANS,
align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, italic=False, line_spacing=1.0):
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
lines = text.split("\n")
for i, line in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
p.line_spacing = line_spacing
r = p.add_run()
r.text = line
r.font.size = Pt(size)
r.font.bold = bold
r.font.italic = italic
r.font.name = font
r.font.color.rgb = color
return tb
def add_bullets(slide, x, y, w, h, items, size=13, color=NEARBLACK, font=SANS, space_after=8):
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = 0
tf.margin_right = 0
for i, item in enumerate(items):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.space_after = Pt(space_after)
r = p.add_run()
r.text = "• " + item
r.font.size = Pt(size)
r.font.name = font
r.font.color.rgb = color
return tb
def add_kicker(slide, text, x=Inches(0.55), y=Inches(0.4)):
add_text(slide, x, y, Inches(6), Inches(0.35), text.upper(), size=11.5, color=ORANGE,
bold=True, font=SANS)
def add_footer(slide, page_num):
add_text(slide, Inches(0.55), Inches(7.12), Inches(6), Inches(0.3),
"Palm Valley Villas | Goodyear, AZ | Phoenix MSA", size=8.5, color=GRAY)
add_text(slide, Inches(12.2), Inches(7.12), Inches(0.6), Inches(0.3),
str(page_num), size=8.5, color=GRAY, align=PP_ALIGN.RIGHT)
def title_bar(slide, title, kicker=None):
if kicker:
add_kicker(slide, kicker)
title_y = Inches(0.72)
else:
title_y = Inches(0.45)
add_text(slide, Inches(0.55), title_y, Inches(12.2), Inches(0.7), title, size=26,
color=NEARBLACK, bold=False, font=SERIF)
add_rect(slide, Inches(0.55), title_y + Inches(0.72), Inches(1.1), Pt(3), ORANGE)
def stat_card(slide, x, y, w, h, label, value, sub=None):
add_rect(slide, x, y, w, h, GREIGE)
add_text(slide, x + Inches(0.18), y + Inches(0.15), w - Inches(0.36), Inches(0.3),
label.upper(), size=9.5, color=GRAY, bold=True)
add_text(slide, x + Inches(0.18), y + Inches(0.45), w - Inches(0.36), Inches(0.55),
value, size=22, color=NEARBLACK, bold=True, font=SERIF)
if sub:
add_text(slide, x + Inches(0.18), y + Inches(0.98), w - Inches(0.36), Inches(0.35),
sub, size=9.5, color=GRAY)
# ============================================================
# SLIDE 1 — Cover
# ============================================================
s = add_slide()
set_bg(s, NEARBLACK)
add_rect(s, Inches(0), Inches(6.9), Inches(13.333), Inches(0.06), ORANGE)
add_text(s, Inches(0.8), Inches(2.6), Inches(11), Inches(0.4), "PROPERTY & MARKET REPORT",
size=13, color=ORANGE, bold=True, font=SANS)
add_text(s, Inches(0.8), Inches(3.05), Inches(11.5), Inches(1.3), "Palm Valley Villas",
size=48, color=WHITE, bold=False, font=SERIF)
add_text(s, Inches(0.8), Inches(4.05), Inches(11), Inches(0.5),
"4200 N Falcon Dr, Goodyear, AZ 85395 | Litchfield Submarket, Phoenix MSA",
size=15, color=RGBColor(0xC9, 0xC7, 0xC1), font=SANS)
add_text(s, Inches(0.8), Inches(6.5), Inches(8), Inches(0.35),
"125 units | Built 2015 | Prepared by RealAI", size=11, color=GRAY, font=SANS)
# ============================================================
# SLIDE 2 — Executive summary
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "The read on Palm Valley Villas", "Executive summary")
add_bullets(s, Inches(0.55), Inches(1.6), Inches(6.0), Inches(4.8), [
"Occupancy has held in the mid-90s (93.6% latest) even as the Litchfield submarket has drifted into the high-80s/low-90s — this single-story build-for-rent asset is outperforming its comp set on demand.",
"In-place rent has slipped 3.1% over the trailing 12 months to $1,692, tracking a broader Phoenix-wide correction, but the property still commands a premium to Litchfield's $1,639 submarket average.",
"New-lease tradeout is negative (-7.2%), meaning renewing tenants are re-signing below what turning units re-lease for — a sign of a market still absorbing 2024-25 supply, not asset-specific weakness.",
"NOI margin is a strong 67.2% of EGI, well above the Phoenix MF benchmark of ~60%, aided by a lean expense load and stable payroll.",
"Phoenix multifamily cap rates have compressed from 5.35% (Q3-25) to 5.25% (Q2-26) — the first sustained compression since the 2022 rate shock — a tailwind for basis if it holds.",
])
stat_card(s, Inches(7.0), Inches(1.6), Inches(2.75), Inches(1.5), "Occupancy", "93.6%", "vs. 90.5% submarket")
stat_card(s, Inches(9.9), Inches(1.6), Inches(2.75), Inches(1.5), "In-place rent", "$1,692", "-3.1% T12")
stat_card(s, Inches(7.0), Inches(3.25), Inches(2.75), Inches(1.5), "NOI margin", "67.2%", "of EGI")
stat_card(s, Inches(9.9), Inches(3.25), Inches(2.75), Inches(1.5), "Retention", "76%", "T12 lease renewals")
stat_card(s, Inches(7.0), Inches(4.9), Inches(2.75), Inches(1.5), "MF cap rate", "5.25%", "Phoenix, 2Q26")
stat_card(s, Inches(9.9), Inches(4.9), Inches(2.75), Inches(1.5), "Last sale", "$200.8K/unit", "Jun-2018")
add_footer(s, 2)
# ============================================================
# SLIDE 3 — Property snapshot
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Asset snapshot", "Property")
left_labels = ["Address", "Submarket / MSA", "Year built", "Unit count", "Avg unit size",
"Total rentable SF", "Construction", "Property type", "Last sale (2018)", "Owner"]
left_vals = ["4200 N Falcon Dr, Goodyear, AZ 85395", "Litchfield / Phoenix, AZ", "2015",
"125 units", "967 SF", "120,875 SF", "Steel frame, single-story",
"Market-rate rental, build-for-rent", "$25.1M ($200,800/unit)", "RN Falcon LLC"]
y0 = Inches(1.55)
for i, (lab, val) in enumerate(zip(left_labels, left_vals)):
yy = y0 + Inches(0.44) * i
add_text(s, Inches(0.55), yy, Inches(2.3), Inches(0.4), lab, size=11.5, color=GRAY, bold=True)
add_text(s, Inches(2.9), yy, Inches(4.0), Inches(0.4), val, size=11.5, color=NEARBLACK)
add_rect(s, Inches(7.15), Inches(1.55), Pt(1.2), Inches(5.3), BORDER)
add_text(s, Inches(7.5), Inches(1.55), Inches(5.3), Inches(0.35), "COMMUNITY AMENITIES", size=11, color=ORANGE, bold=True)
add_bullets(s, Inches(7.5), Inches(1.95), Inches(5.3), Inches(1.8), [
"Gated entry, EV charging stations", "Covered & garage parking, dog park",
"Outdoor pool, BBQ/grill areas",
], size=12)
add_text(s, Inches(7.5), Inches(3.55), Inches(5.3), Inches(0.35), "UNIT AMENITIES", size=11, color=ORANGE, bold=True)
add_bullets(s, Inches(7.5), Inches(3.95), Inches(5.3), Inches(1.8), [
"In-unit washer/dryer, central AC", "Modern kitchen, stone counters, hardwood flooring",
"Private patio, walk-in closets, internet included",
], size=12)
add_text(s, Inches(7.5), Inches(5.55), Inches(5.3), Inches(0.35), "RESIDENT PROFILE", size=11, color=ORANGE, bold=True)
add_bullets(s, Inches(7.5), Inches(5.95), Inches(5.3), Inches(1.0), [
"Median household income $136,835 — well above what's needed to carry rent (14.7% rent-to-income, far below-average burden)",
"Average household age 50.6; 57% single-headed households",
], size=11.5)
add_footer(s, 3)
# ============================================================
# SLIDE 4 — Rent trend
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "In-place rent is easing with the submarket, but from a premium base", "Rent trend")
s.shapes.add_picture("chart_rent_trend.png", Inches(0.55), Inches(1.55), width=Inches(12.2))
add_text(s, Inches(0.55), Inches(6.75), Inches(12.2), Inches(0.5),
"Palm Valley Villas' in-place rent has held a $30-90/mo premium to the Litchfield submarket average through most of the past 24 months, though both have compressed since spring 2026.",
size=11, color=GRAY, italic=True)
add_footer(s, 4)
# ============================================================
# SLIDE 5 — Asking vs in-place (pricing power)
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Asking rent is volatile; in-place rent is the real signal", "Rent trend")
s.shapes.add_picture("chart_asking_vs_inplace.png", Inches(0.55), Inches(1.55), width=Inches(12.2))
add_text(s, Inches(0.55), Inches(6.75), Inches(12.2), Inches(0.5),
"Asking rent swings $150-250/mo month to month on a thin sample (13 units currently marketed) — in-place rent, drawn from all 125 leases, is the more reliable read on where the asset actually clears.",
size=11, color=GRAY, italic=True)
add_footer(s, 5)
# ============================================================
# SLIDE 6 — Occupancy trend
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Occupancy has decoupled from a softening submarket", "Occupancy & demand trend")
s.shapes.add_picture("chart_occupancy_trend.png", Inches(0.55), Inches(1.55), width=Inches(12.2))
add_text(s, Inches(0.55), Inches(6.75), Inches(12.2), Inches(0.5),
"Litchfield submarket occupancy has fallen from ~95% (early 2025) to 90.5% as new supply leased up; Palm Valley Villas has stayed 3-5 points above the submarket for the past year.",
size=11, color=GRAY, italic=True)
add_footer(s, 6)
# ============================================================
# SLIDE 7 — Financial performance
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Lean expense load drives an above-market NOI margin", "Operating performance")
# Left: P&L table
rows = [
("Gross potential rent", "$2,550,948", ""),
("Vacancy loss", "($190,510)", ""),
("Other income", "$343,373", "14.6% of net rent"),
("Effective gross income", "$2,703,810", ""),
("Total operating expenses", "($894,085)", "32.8% of EGI"),
("Net operating income", "$1,809,726", "67.2% of EGI"),
]
y0 = Inches(1.65)
add_rect(s, Inches(0.55), y0, Inches(6.7), Inches(0.42), NEARBLACK)
add_text(s, Inches(0.7), y0 + Inches(0.07), Inches(3.2), Inches(0.3), "LINE ITEM", size=10.5, color=WHITE, bold=True)
add_text(s, Inches(4.5), y0 + Inches(0.07), Inches(1.5), Inches(0.3), "ANNUAL", size=10.5, color=WHITE, bold=True)
add_text(s, Inches(6.0), y0 + Inches(0.07), Inches(1.2), Inches(0.3), "NOTE", size=10.5, color=WHITE, bold=True)
for i, (lab, val, note) in enumerate(rows):
yy = y0 + Inches(0.42) + Inches(0.42) * i
bold_row = lab in ("Effective gross income", "Net operating income")
if bold_row:
add_rect(s, Inches(0.55), yy, Inches(6.7), Inches(0.42), GREIGE)
add_text(s, Inches(0.7), yy + Inches(0.08), Inches(3.7), Inches(0.3), lab, size=11.5,
color=NEARBLACK, bold=bold_row)
add_text(s, Inches(4.5), yy + Inches(0.08), Inches(1.4), Inches(0.3), val, size=11.5,
color=(ORANGE if bold_row and "NOI" in lab or lab=="Net operating income" else NEARBLACK), bold=bold_row)
add_text(s, Inches(6.0), yy + Inches(0.08), Inches(1.2), Inches(0.3), note, size=9.5, color=GRAY)
# Right: opex pie
s.shapes.add_picture("chart_opex_breakdown.png", Inches(7.6), Inches(1.5), width=Inches(5.2))
add_text(s, Inches(7.6), Inches(0.72), Inches(5.2), Inches(0.35), "Where the operating dollar goes", size=13, color=NEARBLACK, bold=True)
add_text(s, Inches(0.55), Inches(6.85), Inches(12.2), Inches(0.4),
"Payroll (9.9% of EGI) and property tax (6.3%) are the two largest expense lines; combined opex ratio of 32.8% sits well under the Phoenix MF benchmark of ~40%.",
size=10.5, color=GRAY, italic=True)
add_footer(s, 7)
# ============================================================
# SLIDE 8 — Market context: cap rates
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Phoenix cap rates are compressing off the 2023 peak", "Market trend — capital markets")
s.shapes.add_picture("chart_caprate_trend.png", Inches(0.55), Inches(1.55), width=Inches(12.2))
add_text(s, Inches(0.55), Inches(6.75), Inches(12.2), Inches(0.5),
"Phoenix MF cap rates ran from 3.5% (2021) to a 5.65% peak (3Q23) as rates repriced, and have eased to 5.25% (2Q26) — a modest tailwind for exit pricing if the trend holds.",
size=11, color=GRAY, italic=True)
add_footer(s, 8)
# ============================================================
# SLIDE 9 — Market context: demand drivers
# ============================================================
s = add_slide()
set_bg(s)
title_bar(s, "Phoenix demand is intact, but supply is the swing factor", "Market trend — supply & demand")
stat_card(s, Inches(0.55), Inches(1.6), Inches(2.9), Inches(1.55), "MSA vacancy", "11.6%", "elevated, supply-driven")
stat_card(s, Inches(3.6), Inches(1.6), Inches(2.9), Inches(1.55), "Units under construction", "16,457", "Phoenix MSA")
stat_card(s, Inches(6.65), Inches(1.6), Inches(2.9), Inches(1.55), "YTD deliveries", "8,454", "vs. 6,032 net absorption")
stat_card(s, Inches(9.7), Inches(1.6), Inches(2.9), Inches(1.55), "1-yr job growth", "3.3%", "Litchfield submarket")
add_bullets(s, Inches(0.55), Inches(3.55), Inches(12.2), Inches(3.0), [
"Demand is showing up: net absorption of 6,032 units year-to-date and 1-year job growth of 3.3% in the immediate submarket confirm households are still forming and working locally.",
"But deliveries (8,454 YTD) are still outrunning absorption, and 16,457 units remain under construction — enough pipeline to keep MSA-wide rent growth pinned down through 2026-27.",
"Net migration into Phoenix remains positive (+9,653, about-average nationally) with in-migrants earning modestly more than out-migrants ($123.7K vs $122.1K median) — a slow-growth, not a hollowing-out, market.",
"The practical read for Palm Valley Villas: the submarket-level rent softness is a supply-absorption story, not a demand problem — which is consistent with this asset's occupancy holding above submarket while rent drifts down with the pack.",
])
add_footer(s, 9)
prs.save("Palm_Valley_Villas_Property_Market_Report.pptx")
print("deck saved")
cd /vercel/sandbox/deck && python3 build_deck.py
Now convert to PDF for citation purposes, then save and present the source PPTX.
cd /vercel/sandbox/deck && libreoffice --headless --convert-to pdf Palm_Valley_Villas_Property_Market_Report.pptx --outdir . 2>&1 | tail -10 && ls -la
Good, converted. Let's quickly render a page as image to sanity check layout.
cd /vercel/sandbox/deck && pdftoppm -png -r 80 -f 1 -l 1 Palm_Valley_Villas_Property_Market_Report.pdf p1 && pdftoppm -png -r 80 -f 4 -l 4 Palm_Valley_Villas_Property_Market_Report.pdf p4 && pdftoppm -png -r 80 -f 7 -l 7 Palm_Valley_Villas_Property_Market_Report.pdf p7 && ls p*.png
Palm Valley Villas holds its own on demand while the submarket softens — a lean cost structure and compressing cap rates are the tailwinds worth watching
The 9-slide deck is built and delivered. Key threads it walks through:
The deck covers the cover/snapshot, rent trend, asking-vs-in-place pricing power, occupancy trend, a full P&L waterfall with expense breakdown, and two market-context slides (cap rates, supply/demand). Happy to add unit-mix-level rent detail or a formal comp set if you want to go a layer deeper.
This is a read-only snapshot of a RealAI analysis.