Skip to content

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:

  1. CTR decays dramatically from 95% (day 0-3) to 21% (day 60+)
  2. CVR remains stable at 5-6% across all age buckets
  3. From Owner = 3x higher CVR (13.95% vs 4.8%)
  4. 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

DistrictCityActive Listings
النرجس (An Narjis)Riyadh1,613
الملقا (Al Malqa)Riyadh1,414
العارض (Al Arid)Riyadh1,130
الرمال (Ar Rimal)Riyadh752
الياسمين (Al Yasmin)Riyadh718
العقيق (Al Aqiq)Riyadh620
ظهرة لبن (Dhahrat Laban)Riyadh606
المونسية (Al Munsiyah)Riyadh600
اليرموك (Al Yarmuk)Riyadh570
الحمرا (Al Hamra)Al Khobar560

Key Findings

1. CTR Decays Dramatically with Age

AgeListingsAvg ImpressionsAvg ViewsCTRCVRSession Rate
0-3 days2684404595.4%5.9%23.5%
4-7 days5101,32810160.0%5.8%22.8%
8-14 days6512,88021139.9%5.8%23.4%
15-30 days1,2295,44131031.2%5.6%20.5%
31-60 days1,73210,79858126.2%5.1%19.9%
60+ days4,19333,4881,31020.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 CreatedAvg VPDBoost Factor
0-1 days333.5x
2-3 days23.62.5x
4-7 days12.51.3x
8-14 days151.6x
15+ days9.41.0x (baseline)

3. From Owner = 3x Higher CVR

AttributesListingsCTRCVR
Has Images + From Owner18213.0%13.95%
Has Images + Agent4,61521.4%4.8%
No Images + From Owner1139.0%12.46%
No Images + Agent35142.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 BucketListingsAvg AgeCTRSession RateFavorite RateShare Rate
10%+ (High)5717041.4%32.4%11.43%3.01%
5-10% (Mid)1,5288528.1%22.6%7.87%1.88%
2-5% (Low)1,9249223.1%17.2%5.05%1.26%
<2% (V.Low)1,0727625.6%12.8%2.82%0.81%

Favorites and shares are 4x higher for top-converting listings!

5. Contact Type Breakdown

Contact MethodShare
WhatsApp71.7%
Calls25.5%
Messages2.7%

Proposed Scoring Function

Design Principles

  1. Freshness Boost (Days 0-7): New listings get temporary boost to collect engagement data
  2. Engagement-Based Ranking (Day 7+): Rank by normalized engagement metrics
  3. Quality Signals: CTR, CVR, session rate, favorites, shares
  4. 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 boost

Engagement 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 score

SQL Implementation

-- Calculate listing score
SELECT
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_score
FROM
listings_with_metrics l
JOIN segment_percentiles p ON l.category = p.category
AND l.district_id = p.district_id
ORDER BY
final_score DESC

Minimum Data Thresholds

Before using engagement-based scoring, ensure sufficient data:

MetricMinimum Threshold
Impressions≥ 100
Views≥ 20
Days Active≥ 7

For listings below thresholds, use freshness boost or segment average.


Expected Impact

MetricCurrent StateExpected After
Avg CTR (new listings)95% (too high, wasted impressions)~60% (more targeted)
Avg CVR (overall)5.5%6.5%+ (better matching)
New listing visibilityOver-boosted day 0-3Sustained boost day 0-7
High-quality listing rankBased on total viewsBased on engagement quality

Monitoring Metrics

Track these to validate the new scoring:

  1. CTR by age bucket - Should become more stable
  2. CVR by age bucket - Should improve for older listings
  3. Favorite rate vs rank - Should correlate positively
  4. From Owner performance - Should improve with explicit boost
  5. 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_pct
FROM
(
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_bucket
ORDER BY
age_bucket

Engagement 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_pct
FROM
(
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_bucket
ORDER BY
cvr_bucket