🏠 Property Lead Tracker

How the data, the reports, and the monthly notes are built

Python pandas Jinja2 Power BI / DAX Claude API

🔍 About this code showcase

These snippets show the three pieces that make the system trustworthy: how each lead is labelled, how a landlord report is written from the numbers, and how the monthly recommendations are decided.

The full project (dataset, generators, dashboard, owner-report template, insight engine, and the Power BI spec) lives in projects/property-lead-tracker/. Every figure on every page traces back to one seeded dataset of 1,140 enquiries.

📁 Project structure

projects/property-lead-tracker/ generate_dataset.py # seeded synthetic data (1,140 enquiries, reproducible) listings.csv enquiries.csv # the fact table + the 10 listings ad_spend.csv platform_costs.csv ui-1-internal-dashboard.html # the live 5-tab agent dashboard (Chart.js) ui-2-owner-report.html # white-label owner report, live skin toggle ui-3-case-study.html # the public case study, links to everything owner-reports/ owner_report_template.html.j2 # Jinja2 template (one CSS block per agency) generate_owner_reports.py # reads the data, writes a report per listing x month out/ # two generated samples, two agency skins insights/ generate_insights.py # threshold rules -> monthly written notes 2026-03-notes.md ... # one notes file per month powerbi-spec/ data-model.md measures.dax pages.md # the same dashboard, built in Power BI

🏷️ 1. Labelling each lead: where it came from, how good it is

The whole analysis rests on labelling every enquiry by channel, with a realistic quality and conversion profile for each. This is the table that lets the dashboard separate the busy channel from the one that actually signs tenants.

📄 generate_dataset.py — channel behaviour profiles
# share = how much volume each channel brings; q = how many are genuinely qualified; # view = chance of a viewing; show = chance they turn up. Referral is small but golden. SOURCES = { "mudah": dict(share=0.38, q=0.15, view=0.30, show=0.55), # high volume, low quality "propertyguru": dict(share=0.27, q=0.42, view=0.55, show=0.75), # the workhorse "meta_ads": dict(share=0.16, q=0.35, view=0.45, show=0.65), # rooms only "google_ads": dict(share=0.11, q=0.55, view=0.68, show=0.80), # pricey but converts "referral": dict(share=0.08, q=0.75, view=0.80, show=0.90), # the invisible champion }

📝 2. Writing the landlord report from the numbers

The owner report is not hand-typed. A rule reads each listing's month and decides the one recommendation that matters: reprice a stale unit, celebrate a signing, or stay the course. The reprice range is computed from the asking rent, so the advice is always grounded in the data.

📄 owner-reports/generate_owner_reports.py — the recommendation rule
def recommendation(f, anchor): rent = int(f["row"].asking_rent_myr) if f["signed_this_month"]: return celebrate_signing() # the unit is tenanted, hand over if f["is_active"] and f["velocity_drop"] >= 0.5 and f["weeks_on_market"] >= 6: low = round(rent * 0.88 / 50) * 50 # reprice band, rounded to RM50 high = round(rent * 0.913 / 50) * 50 return reprice_advice(low, high, anchor) # a stale unit: review the price if f["new_enq"] >= 4 and f["viewings"] >= 1: return stay_the_course() # healthy: interest is converting return refresh_listing() # quiet month: refresh and re-feature

🚩 3. The monthly flag: catching a stale listing automatically

The insight engine compares a unit's recent enquiry pace against its opening fortnight. When interest has halved and the unit is still empty after six weeks, it writes the flag, names a cheaper comparable that kept moving, and recommends a price review instead of more ad spend.

📄 insights/generate_insights.py — the stale-listing rule
if drop >= 0.5 and weeks_on >= 6: # find a cheaper, comparable unit that already signed anchor = cheaper_comparable(listing) yield ("flag", f"{name} (RM{rent}) has gone stale: enquiry velocity is down " f"{drop:.0%} from its opening fortnight after {weeks_on} weeks. " f"Recommend a price review toward RM{low}-{high}, not more ad spend.")

📐 4. The same answer in Power BI (DAX)

For a client who runs on Microsoft tools, the headline measure is one line. It returns blank when a channel produced no tenancies, which the dashboard shows as "Undefined", because cost divided by zero tenants is not zero, it is unanswerable.

📄 powerbi-spec/measures.dax — the headline measure
// HEADLINE: spend per signed tenancy, by channel Cost per Tenancy = DIVIDE ( [Source Cost], [Tenancies Signed] ) // shown as "Undefined" when a channel signed nobody (e.g. Mudah) Cost per Tenancy (label) = IF ( ISBLANK ( [Cost per Tenancy] ), "Undefined", FORMAT ( [Cost per Tenancy], "\R\M #,0" ) )