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:

  1. Subject property summary - address, unit type, current in-place rent, sqft, date of report
  2. Comparable listings table - 10-15 comps with address, bedrooms, sqft, rent, $/sqft, and days on market
  3. Market statistics summary - median rent, P25/P75 range, median $/sqft, comp count, median DOM
  4. 12-month rent trend - chart or table showing how median rents have moved month-over-month in the subject neighborhood
  5. 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
  6. Vacancy estimate - current estimated vacancy rate for the submarket
  7. 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:

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="[email protected]",
        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

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