Apartment Rental Scoring Function Analysis
Date: 2026-01-18 Focus: Apartments for Rent (category=1) in top Riyadh districts
Executive Summary
Analysis of 8,583 apartment rental listings in top Riyadh districts reveals that:
- CTR decays dramatically from 95% (day 0-3) to 21% (day 60+)
- CVR remains stable at 5-6% across all age buckets
- From Owner = 3x higher CVR (13.95% vs 4.8%)
- Favorites/Shares predict quality - 4x higher for top-converting listings
This suggests a scoring function that boosts fresh listings for 3-7 days, then ranks by engagement metrics (CTR, CVR, favorites, session rate).
Top Districts Analyzed
| District | City | Active Listings |
|---|---|---|
| النرجس (An Narjis) | Riyadh | 1,613 |
| الملقا (Al Malqa) | Riyadh | 1,414 |
| العارض (Al Arid) | Riyadh | 1,130 |
| الرمال (Ar Rimal) | Riyadh | 752 |
| الياسمين (Al Yasmin) | Riyadh | 718 |
| العقيق (Al Aqiq) | Riyadh | 620 |
| ظهرة لبن (Dhahrat Laban) | Riyadh | 606 |
| المونسية (Al Munsiyah) | Riyadh | 600 |
| اليرموك (Al Yarmuk) | Riyadh | 570 |
| الحمرا (Al Hamra) | Al Khobar | 560 |
Key Findings
1. CTR Decays Dramatically with Age
| Age | Listings | Avg Impressions | Avg Views | CTR | CVR | Session Rate |
|---|---|---|---|---|---|---|
| 0-3 days | 268 | 440 | 45 | 95.4% | 5.9% | 23.5% |
| 4-7 days | 510 | 1,328 | 101 | 60.0% | 5.8% | 22.8% |
| 8-14 days | 651 | 2,880 | 211 | 39.9% | 5.8% | 23.4% |
| 15-30 days | 1,229 | 5,441 | 310 | 31.2% | 5.6% | 20.5% |
| 31-60 days | 1,732 | 10,798 | 581 | 26.2% | 5.1% | 19.9% |
| 60+ days | 4,193 | 33,488 | 1,310 | 20.8% | 5.2% | 18.0% |
Key insight: CTR drops 4.6x from new to old listings, while CVR stays stable. The funnel bottleneck is at impressions→views, not views→contacts.
2. Freshness Boost Pattern (VPD by Age)
| Days Since Created | Avg VPD | Boost Factor |
|---|---|---|
| 0-1 days | 33 | 3.5x |
| 2-3 days | 23.6 | 2.5x |
| 4-7 days | 12.5 | 1.3x |
| 8-14 days | 15 | 1.6x |
| 15+ days | 9.4 | 1.0x (baseline) |
3. From Owner = 3x Higher CVR
| Attributes | Listings | CTR | CVR |
|---|---|---|---|
| Has Images + From Owner | 182 | 13.0% | 13.95% |
| Has Images + Agent | 4,615 | 21.4% | 4.8% |
| No Images + From Owner | 11 | 39.0% | 12.46% |
| No Images + Agent | 351 | 42.4% | 6.3% |
Owner listings have 2.9x higher CVR (13.95% vs 4.8%) despite lower CTR.
4. Engagement Signals Correlate with Quality
| CVR Bucket | Listings | Avg Age | CTR | Session Rate | Favorite Rate | Share Rate |
|---|---|---|---|---|---|---|
| 10%+ (High) | 571 | 70 | 41.4% | 32.4% | 11.43% | 3.01% |
| 5-10% (Mid) | 1,528 | 85 | 28.1% | 22.6% | 7.87% | 1.88% |
| 2-5% (Low) | 1,924 | 92 | 23.1% | 17.2% | 5.05% | 1.26% |
| <2% (V.Low) | 1,072 | 76 | 25.6% | 12.8% | 2.82% | 0.81% |
Favorites and shares are 4x higher for top-converting listings!
5. Contact Type Breakdown
| Contact Method | Share |
|---|---|
| 71.7% | |
| Calls | 25.5% |
| Messages | 2.7% |
Proposed Scoring Function
Design Principles
- Freshness Boost (Days 0-7): New listings get temporary boost to collect engagement data
- Engagement-Based Ranking (Day 7+): Rank by normalized engagement metrics
- Quality Signals: CTR, CVR, session rate, favorites, shares
- Owner Boost: Owner listings get explicit boost due to higher CVR
Freshness Boost Function
def freshness_boost(days_since_created): if days_since_created <= 1: return 3.5 # Day 0-1: maximum boost elif days_since_created <= 3: return 2.5 # Day 2-3: high boost elif days_since_created <= 7: return 1.5 # Day 4-7: moderate boost else: return 1.0 # Day 8+: no boostEngagement Score (for listings with sufficient data)
def engagement_score(listing): # Normalize each metric to 0-1 range within segment ctr_score = normalize(listing.ctr, segment_ctr_p10, segment_ctr_p90) cvr_score = normalize(listing.cvr, segment_cvr_p10, segment_cvr_p90) session_score = normalize(listing.session_rate, segment_session_p10, segment_session_p90) favorite_score = normalize(listing.favorite_rate, segment_fav_p10, segment_fav_p90)
# Weighted combination (weights based on correlation with CVR) return ( 0.25 * ctr_score + # CTR: 1.6x correlation 0.30 * cvr_score + # CVR: direct conversion metric 0.20 * session_score + # Session: 2.5x correlation 0.25 * favorite_score # Favorites: 4x correlation )Combined Scoring Formula
def listing_score(listing): days = days_since_created(listing)
# Base score if days <= 7: # New listings: use freshness boost base_score = freshness_boost(days) else: # Established listings: use engagement score base_score = engagement_score(listing)
# Apply multipliers score = base_score score *= 1.0 + (0.3 * listing.from_owner) # +30% for owner score *= 1.0 + (0.2 * listing.has_images) # +20% for images score *= 1.0 + (0.15 * listing.has_video) # +15% for video score *= 1.0 + (2.0 * listing.is_premium) # +200% for premium
return scoreSQL Implementation
-- Calculate listing scoreSELECT l.id, l.district_id, days_since_created, -- Freshness boost (first 7 days) multiIf ( days_since_created <= 1, 3.5, days_since_created <= 3, 2.5, days_since_created <= 7, 1.5, 1.0 ) AS freshness_boost, -- Engagement score (normalized within segment) ( 0.25 * (ctr - segment_ctr_p10) / nullIf(segment_ctr_p90 - segment_ctr_p10, 0) + 0.30 * (cvr - segment_cvr_p10) / nullIf(segment_cvr_p90 - segment_cvr_p10, 0) + 0.20 * (session_rate - segment_session_p10) / nullIf(segment_session_p90 - segment_session_p10, 0) + 0.25 * (favorite_rate - segment_fav_p10) / nullIf(segment_fav_p90 - segment_fav_p10, 0) ) AS engagement_score, -- Combined score if ( days_since_created <= 7, freshness_boost, greatest (engagement_score, 0.1) ) * (1 + 0.3 * from_owner) * (1 + 0.2 * has_img) * (1 + 2.0 * is_premium) AS final_scoreFROM listings_with_metrics l JOIN segment_percentiles p ON l.category = p.category AND l.district_id = p.district_idORDER BY final_score DESCMinimum Data Thresholds
Before using engagement-based scoring, ensure sufficient data:
| Metric | Minimum Threshold |
|---|---|
| Impressions | ≥ 100 |
| Views | ≥ 20 |
| Days Active | ≥ 7 |
For listings below thresholds, use freshness boost or segment average.
Expected Impact
| Metric | Current State | Expected After |
|---|---|---|
| Avg CTR (new listings) | 95% (too high, wasted impressions) | ~60% (more targeted) |
| Avg CVR (overall) | 5.5% | 6.5%+ (better matching) |
| New listing visibility | Over-boosted day 0-3 | Sustained boost day 0-7 |
| High-quality listing rank | Based on total views | Based on engagement quality |
Monitoring Metrics
Track these to validate the new scoring:
- CTR by age bucket - Should become more stable
- CVR by age bucket - Should improve for older listings
- Favorite rate vs rank - Should correlate positively
- From Owner performance - Should improve with explicit boost
- New listing success rate - % converting to high-engagement after 7 days
SQL Queries Used
CTR/CVR by Age Bucket
SELECT age_bucket, count() AS listings, round(avg(total_impressions), 0) AS avg_impr, round(avg(total_views), 0) AS avg_views, round(avg(ctr) * 100, 2) AS avg_ctr_pct, round(avg(cvr) * 100, 2) AS avg_cvr_pctFROM ( SELECT l.id, coalesce(sum(s.impressions), 0) AS total_impressions, coalesce(sum(s.views), 0) AS total_views, coalesce(sum(s.calls + s.whatsapp + s.msgs), 0) AS total_contacts, total_views / greatest (total_impressions, 1) AS ctr, total_contacts / greatest (total_views, 1) AS cvr, dateDiff ( 'day', toDate (fromUnixTimestamp (l.create_time)), today () ) AS days, multiIf ( days <= 3, 'a_0-3d', days <= 7, 'b_4-7d', days <= 14, 'c_8-14d', days <= 30, 'd_15-30d', days <= 60, 'e_31-60d', 'f_60+d' ) AS age_bucket FROM sadb_listings l FINAL LEFT JOIN sadb_stats_listings_stats s FINAL ON l.id = s.id AND toDate (s.day_time) >= '2024-01-01' WHERE l.category = 1 AND l.district_id IN (600, 570, 494, 446, 632, 504, 688, 590, 634, 1070) AND l.status IN (0, 4) AND l.published = 1 AND l.hidden = 0 AND l.rega_licensed = 1 AND l._peerdb_is_deleted = 0 AND toDate (fromUnixTimestamp (l.create_time)) >= '2024-01-01' GROUP BY l.id, days )GROUP BY age_bucketORDER BY age_bucketEngagement Signals by CVR Bucket
SELECT cvr_bucket, count() AS listings, round(avg(days), 0) AS avg_age, round(avg(ctr) * 100, 1) AS avg_ctr, round(avg(session_rate) * 100, 1) AS sess_rate, round(avg(fav_rate) * 100, 2) AS fav_pctFROM ( SELECT l.id, dateDiff ( 'day', toDate (fromUnixTimestamp (l.create_time)), today () ) AS days, coalesce(sum(s.impressions), 0) AS impr, coalesce(sum(s.views), 0) AS views, coalesce(sum(s.calls + s.whatsapp + s.msgs), 0) AS contacts, coalesce(sum(s.session_30_seconds), 0) AS sessions, coalesce(sum(s.favorited), 0) AS favs, views / greatest (impr, 1) AS ctr, contacts / greatest (views, 1) AS cvr, sessions / greatest (views, 1) AS session_rate, favs / greatest (views, 1) AS fav_rate, multiIf ( cvr >= 0.1, 'a_10%+', cvr >= 0.05, 'b_5-10%', cvr >= 0.02, 'c_2-5%', 'd_<2%' ) AS cvr_bucket FROM sadb_listings l FINAL LEFT JOIN sadb_stats_listings_stats s FINAL ON l.id = s.id AND toDate (s.day_time) >= '2024-01-01' WHERE l.category = 1 AND l.district_id IN (600, 570, 494, 446, 632) AND l.status IN (0, 4) AND l.published = 1 AND l.hidden = 0 AND l.rega_licensed = 1 AND l._peerdb_is_deleted = 0 GROUP BY l.id, days HAVING views >= 50 )GROUP BY cvr_bucketORDER BY cvr_bucket