Every property manager has had this conversation: an owner calls asking why rents at the property are lower than "what they read online." The honest answer is that the landlord is comparing their stabilized in-place rents to peak asking rents on Zillow from a different submarket. The better answer is a rental market report - a structured document that shows current comp data, trend lines, vacancy conditions, and where the property's in-place rents sit relative to market. Generated automatically. Delivered to the owner every month without anyone touching a spreadsheet.
This tutorial walks through building exactly that: an automated rental market report pipeline using Python, FastAPI, and the RentComp API. The output is a clean PDF that a property manager can send to investors, include in a DSCR loan package, or use for internal pricing decisions.
Who Uses Rental Market Reports
Before getting into the build, it is worth being specific about who consumes these reports and what they need - because the content requirements differ meaningfully:
Investors (acquisition due diligence and portfolio monitoring). Need to see where in-place rents sit vs market rents, a 12-month trend showing direction, and vacancy context. If in-place rents are 15% below market, that is an upside story. If market rents have declined for three consecutive months, that changes the exit cap assumption.
Lenders (DSCR underwriting files). Need a defensible market rent opinion they can cite in the appraisal review. The report must show the comp methodology: address, radius, number of comps, data date, and the resulting median. The HUD Fair Market Rent comparison is a useful addition because it gives the lender a government-published anchor to validate against.
Property managers (owner reporting). Need a monthly touchpoint that demonstrates active market monitoring. Owners are reassured when they see their PM provider pulled fresh comps and the in-place rent is within a defensible range of market. The report should be clean, branded, and take less than 2 minutes to skim.
What a Complete Report Contains
A report that serves all three audiences well contains these sections:
- Subject property summary - address, unit type, current in-place rent, sqft, date of report
- Comparable listings table - 10-15 comps with address, bedrooms, sqft, rent, $/sqft, and days on market
- Market statistics summary - median rent, P25/P75 range, median $/sqft, comp count, median DOM
- 12-month rent trend - chart or table showing how median rents have moved month-over-month in the subject neighborhood
- HUD Fair Market Rent comparison - current FMR for the metro/county alongside the market median, showing whether market rents are above or below the HUD benchmark
- Vacancy estimate - current estimated vacancy rate for the submarket
- Positioning summary - where the subject property's in-place rent sits relative to market (e.g., "7.2% below market median, within normal range")
Tech Stack
The stack is intentionally minimal:
- Python 3.11+ - report generation logic
- FastAPI - optional HTTP endpoint if you want to trigger reports via webhook or UI button
- Jinja2 - HTML template rendering
- WeasyPrint - HTML-to-PDF conversion (produces clean, print-ready output from your Jinja2 template)
- Boto3 / SendGrid - for delivery (S3 upload or email attachment)
Install dependencies: pip install fastapi requests jinja2 weasyprint boto3 sendgrid
Note on WeasyPrint: on Windows, you will need to install GTK3 runtime separately. On Linux/Docker, apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 covers the dependencies. For production deployments, running this in a Docker container based on python:3.11-slim with the GTK libraries pre-installed is the cleanest approach.
Step 1: Pull Comp Data and Market Statistics
The first API call pulls the current comp table and market statistics for the subject property. This gives you the comparable listings table and the summary statistics block.
import requests
from datetime import datetime, date
API_KEY = "your_api_key_here"
BASE_URL = "https://api.rentcompapi.com/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def fetch_comps(address: str, bedrooms: int, radius_miles: float = 0.5) -> dict:
"""Fetch comp table and market stats for a subject property."""
response = requests.post(
f"{BASE_URL}/comps",
headers=HEADERS,
json={
"address": address,
"bedrooms": bedrooms,
"radius_miles": radius_miles,
"min_comps": 8,
"include_market_stats": True,
"include_individual_comps": True
}
)
response.raise_for_status()
return response.json()
The response includes a comps array (individual comparable listings) and a market_stats object with median rent, $/sqft, DOM, and vacancy data.
Step 2: Pull the 12-Month Trend Series
The trends endpoint returns monthly median rent data for up to 24 months. You use this to build the trend chart and show rent direction.
def fetch_trends(address: str, bedrooms: int, months: int = 12) -> list:
"""Fetch monthly median rent trend for the submarket."""
response = requests.get(
f"{BASE_URL}/trends",
headers=HEADERS,
params={
"address": address,
"bedrooms": bedrooms,
"months": months
}
)
response.raise_for_status()
data = response.json()
# Returns list of {month: "2025-03", median_rent: 1840, comp_count: 14}
return data.get("monthly_data", [])
The trend data feeds two things in the report: a simple HTML bar chart in the Jinja2 template (no external charting library needed), and the YoY change calculation that shows percentage rent growth or decline over 12 months. For a deeper discussion of how trend data factors into investment analysis, see our post on real estate investment analysis with rental data API.
Step 3: Pull HUD Fair Market Rent
def fetch_fair_market_rent(address: str, bedrooms: int) -> dict:
"""Fetch HUD FMR for the county containing the address."""
response = requests.get(
f"{BASE_URL}/fair-market-rent",
headers=HEADERS,
params={"address": address, "bedrooms": bedrooms}
)
response.raise_for_status()
return response.json()
# Returns {fmr: 1420, metro_name: "Chicago-Naperville-Elgin", fiscal_year: 2026}
HUD FMRs are set at the 40th percentile of gross rents for standard quality units. They are published annually (typically in October for the following fiscal year). HUD FMR data is a useful sanity check: if your market median is significantly below FMR, something is off with your comp set. If it is well above FMR, that tells you the market is in the upper half of the income distribution for that area - which is useful context for DSCR lenders.
Step 4: Render the HTML Template with Jinja2
Create a Jinja2 template at templates/rental_market_report.html. The template should be a standalone HTML file with inline CSS - this is important for WeasyPrint PDF rendering, which does not load external stylesheets reliably. Key template variables:
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
import os
def render_report_html(report_data: dict) -> str:
"""Render report data into HTML string using Jinja2 template."""
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("rental_market_report.html")
return template.render(**report_data)
def generate_pdf(html_content: str, output_path: str) -> str:
"""Convert rendered HTML to PDF using WeasyPrint."""
HTML(string=html_content).write_pdf(output_path)
return output_path
Step 5: The Report Orchestration Function
This ties the three data fetches together and feeds the template:
def generate_rental_market_report(
address: str,
bedrooms: int,
in_place_rent: float,
property_name: str = None,
output_dir: str = "./reports"
) -> str:
"""
Generate a complete rental market report PDF.
Returns the path to the generated PDF.
"""
os.makedirs(output_dir, exist_ok=True)
report_date = date.today()
# Fetch all data
comp_data = fetch_comps(address, bedrooms)
trend_data = fetch_trends(address, bedrooms)
fmr_data = fetch_fair_market_rent(address, bedrooms)
market_stats = comp_data["market_stats"]
median_rent = market_stats["median_rent"]
# Calculate positioning
rent_vs_market = ((in_place_rent - median_rent) / median_rent) * 100
trend_12m = None
if len(trend_data) >= 12:
oldest = trend_data[0]["median_rent"]
newest = trend_data[-1]["median_rent"]
trend_12m = ((newest - oldest) / oldest) * 100
report_data = {
"address": address,
"property_name": property_name or address,
"bedrooms": bedrooms,
"in_place_rent": in_place_rent,
"report_date": report_date.strftime("%B %d, %Y"),
"comps": comp_data.get("comps", [])[:15], # Top 15 comps
"market_stats": market_stats,
"trend_data": trend_data,
"fmr": fmr_data,
"rent_vs_market_pct": round(rent_vs_market, 1),
"trend_12m_pct": round(trend_12m, 1) if trend_12m else None,
"median_rent": median_rent,
}
html = render_report_html(report_data)
filename = f"market-report-{report_date.isoformat()}.pdf"
output_path = os.path.join(output_dir, filename)
generate_pdf(html, output_path)
return output_path
Scheduling and Delivery
Cron Job for Automated Delivery
For a portfolio of 50 properties, a nightly or weekly cron job generates reports for all addresses and delivers them to S3 or via email. A simple approach using a properties JSON file and cron:
#!/usr/bin/env python3
# run_reports.py - Called by cron weekly
import json
import boto3
from generate_report import generate_rental_market_report
with open("portfolio.json") as f:
properties = json.load(f)
s3 = boto3.client("s3", region_name="us-east-1")
BUCKET = "your-reports-bucket"
for prop in properties:
pdf_path = generate_rental_market_report(
address=prop["address"],
bedrooms=prop["bedrooms"],
in_place_rent=prop["current_rent"],
property_name=prop["name"]
)
# Upload to S3 with pre-signed URL (expires in 7 days)
s3_key = f"reports/{prop['id']}/latest.pdf"
s3.upload_file(pdf_path, BUCKET, s3_key)
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": s3_key},
ExpiresIn=604800 # 7 days
)
print(f"Report for {prop['name']}: {url}")
Email Delivery via SendGrid
import sendgrid
from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
import base64
def email_report(to_email: str, pdf_path: str, property_name: str):
"""Send report PDF as email attachment via SendGrid."""
with open(pdf_path, "rb") as f:
pdf_data = base64.b64encode(f.read()).decode()
message = Mail(
from_email="reports@yourpmcompany.com",
to_emails=to_email,
subject=f"Monthly Market Report: {property_name}",
html_content=f"Please find the monthly rental market report for {property_name} attached.
"
)
attachment = Attachment(
FileContent(pdf_data),
FileName(f"market-report-{property_name}.pdf"),
FileType("application/pdf"),
Disposition("attachment")
)
message.attachment = attachment
sg = sendgrid.SendGridAPIClient(api_key="your_sendgrid_key")
sg.send(message)
White-Labeling for PM Software Vendors
If you are building this as a feature inside a property management platform rather than for your own portfolio, the same pipeline works with two modifications:
First, parameterize the Jinja2 template to accept a brand object containing the company name, logo URL, primary color, and contact information. Each client tenant in your platform gets their own brand config, and the rendered PDF carries their branding instead of yours.
Second, expose a FastAPI endpoint at POST /reports/generate that accepts address, bedrooms, in-place rent, and brand parameters, generates the report asynchronously (using a task queue like Celery or a background task), and returns a download URL. This lets your PM software trigger report generation from a "Generate Report" button in the UI without synchronous blocking.
For how to structure the broader API integration inside a property management application, see our post on how to automate rent pricing with market data.
Performance note: The three API calls (comps, trends, FMR) plus WeasyPrint PDF rendering takes 3-6 seconds per report on average. For bulk generation of 50+ reports, run them in parallel with concurrent.futures.ThreadPoolExecutor capped at 5-10 concurrent workers to stay within API rate limits.
Common Issues and Fixes
- WeasyPrint fonts look wrong in PDF - use web-safe fonts (Arial, Georgia) in your template or embed font files explicitly. Custom fonts require the @font-face src to be a file:// path, not a CDN URL.
- Charts don't render in PDF - WeasyPrint does not execute JavaScript, so Chart.js and similar libraries will produce blank output. Use static SVG charts generated server-side, or use an HTML table with CSS bar charts (div widths calculated as percentages). Pure HTML/CSS charts render perfectly.
- Page breaks in comp table - add page-break-inside: avoid to your table row CSS to prevent rows from splitting across pages.
- Thin comp sets in rural markets - if fewer than 8 comps are returned, expand radius to 1 mile and note the wider comp geography in the report. Transparency about methodology builds trust with lenders.
The full pipeline - data fetching, template rendering, PDF generation, and delivery - runs in under 10 seconds per property and can be triggered on a schedule or on-demand. Once it is running, the ongoing cost of generating market reports for a 100-unit portfolio is a few API calls per property per month. The RentComp API pricing is designed to make this kind of automated reporting economical even for small PM operators.
Ready to Pull Rental Comps via API?
Join the waitlist and get 80% off founding member pricing - for life.
Join the Waitlist