Evaluate the unit pricing at Main Street Towns at Lilburn against comparable rent comps in the surrounding market, including how in-place and asking rents compare by bedroom type. Use my Yardi data and pricing history. Select comps based on distance, unit mix overlap, unit count, building style, and vintage or quality, then flag whether our pricing is both competitive and maximizes long term revenue. give 1–3 actionable pricing recommendations if applicable.
Now Yardi subject data: unit mix, market rent, occupancy, in-place rent.
Let me find the subject in the Datamart and pull the comp pool.
mkdir -p extracts scripts && cp tool-outputs/comps_lilburn_place.json tool-outputs/comps_gwinnett_county.json extracts/ && cat > scripts/comp_pool.py <<'EOF'
import json, math
SUBJ_ID="dea1bb1fdba58ff614840cbff071ea63"; LAT,LON=33.89249771833428,-84.14109528064728
rows={}
for f in ["extracts/comps_lilburn_place.json","extracts/comps_gwinnett_county.json"]:
d=json.load(open(f)); d=d.get("data",d) if isinstance(d,dict) else d
for r in d: rows[r["id"]]=r
rows.pop(SUBJ_ID,None)
def hv(lat,lon):
R=3958.8;p1,p2=math.radians(LAT),math.radians(lat);dp=p2-p1;dl=math.radians(lon-LON)
a=math.sin(dp/2)**2+math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2;return 2*R*math.asin(math.sqrt(a))
out=[]
for r in rows.values():
a=r["mf_property_attributes"];s=r["mf_rent_and_occupancy_snapshot"]
hr=a.get("household_restrictions"); restr="none"
if hr:
try:
j=json.loads(hr); act=[k for k,v in j.items() if v]; restr=",".join(act) if act else "none"
except: restr="unknown"
f=lambda k: float(s[k]) if s.get(k) not in (None,"") else None
out.append(dict(id=r["id"],name=r["name"],city=r["address_city"],dist=round(hv(a["latitude"],a["longitude"]),2),units=a["unit_count"],style=a["building_style"],yb=a["year_built"],yr=a.get("year_renovated"),sqft=a.get("unit_size_sqft"),restr=restr,
ask3=f("asking_rent_latest_3_bed"),ip3=f("in_place_rent_latest_3_bed"),ask3psf=f("asking_rent_sqft_latest_3_bed"),ip3psf=f("in_place_rent_sqft_latest_3_bed"),
ask2=f("asking_rent_latest_2_bed"),ip2=f("in_place_rent_latest_2_bed"),ask4=f("asking_rent_latest_4_bed"),ip4=f("in_place_rent_latest_4_bed"),
ask_avg=f("asking_rent_latest_avg"),ip_avg=f("in_place_rent_latest_avg"),occ=f("occupancy_latest"),ret=s.get("retention_rate"),dom=s.get("days_on_market_leases_signed_past_30d_median"),
ask_t12=f("asking_rent_t12_pct_chg_median"),ip_t12=f("in_place_rent_t12_pct_chg_median"),conf=s.get("sample_confidence"),asof=s.get("latest_as_of")))
out.sort(key=lambda x:x["dist"])
json.dump(out,open("extracts/comp_pool_merged.json","w"),indent=1)
print(len(out),"candidates; styles:",sorted(set(str(o["style"]) for o in out)))
print("within 5mi:",sum(o["dist"]<5 for o in out))
for o in out[:30]: print(o["dist"],o["name"][:32],o["units"],o["style"],o["yb"],o["yr"],o["restr"],o["sqft"],"ask3",o["ask3"],"ip3",o["ip3"],"occ",o["occ"])
print("--- TOWNHOUSE or 3BR-heavy newer anywhere in county ---")
for o in out:
if (o["style"]=="TOWNHOUSE") or (o["yb"] and int(o["yb"])>=2015 and o["ip3"]):
print(o["dist"],o["name"][:32],o["units"],o["style"],o["yb"],o["restr"],o["sqft"],"ask3",o["ask3"],"ip3",o["ip3"],"psf",o["ip3psf"],"occ",o["occ"])
EOF
python3 scripts/comp_pool.pycp tool-outputs/comps_metro_th_btr.json extracts/ && sed -i 's#for f in \["extracts/comps_lilburn_place.json","extracts/comps_gwinnett_county.json"\]#for f in ["extracts/comps_lilburn_place.json","extracts/comps_gwinnett_county.json","extracts/comps_metro_th_btr.json"]#' scripts/comp_pool.py && python3 - <<'EOF'
import subprocess,json
subprocess.run(["python3","scripts/comp_pool.py"],capture_output=True)
out=json.load(open("extracts/comp_pool_merged.json"))
print(len(out))
for o in out:
if o["style"] in ("TOWNHOUSE","BUILD_FOR_RENT") and o["dist"]<20:
print(o["dist"],o["name"][:32],o["city"],o["units"],o["style"],o["yb"],o["restr"],o["sqft"],"ask3",o["ask3"],"ip3",o["ip3"],"psf",o["ask3psf"],o["ip3psf"],"occ",o["occ"],"ret",o["ret"],"dom",o["dom"],o["conf"])
EOFpython3 - <<'EOF'
import json
out=json.load(open("extracts/comp_pool_merged.json"))
names=["Parkview Place","Monfort Reserve","Generation Avondale East","Residences on Paper Mill","Carlton Crossing","The Mabry","The Darnell","Lyra by Radius","Celadon on Club Apartments","District at Parkview","SkyMor Panola","Towns on Church","Henley Avondale","The Maddox","Oakwood Vista Apartments"]
sel=[o for o in out if o["name"] in names]
print(json.dumps([o["id"] for o in sel]))
for o in sel: print(o["id"],o["name"])
EOFcp tool-outputs/comp_amenities.json extracts/ && python3 - <<'EOF'
import json
d=json.load(open("extracts/comp_amenities.json")); d=d.get("data",d)
for r in d:
a=r["mf_amenities"]
def flat(s):
if not s: return "-"
j=json.loads(s); return "; ".join(v if isinstance(v,str) else ",".join(map(str,v)) for k,v in j.items()) if isinstance(j,dict) else str(j)
print("##",r["name"]); print(" C:",flat(a["community_amenities"])[:400]); print(" U:",flat(a["unit_amenities"])[:300])
EOFcat > scripts/comp_analysis.py <<'EOF'
import json, statistics as st
out={o["name"]:o for o in json.load(open("extracts/comp_pool_merged.json"))}
SEL=[("Parkview Place","Product"),("Monfort Reserve","Product"),("Generation Avondale East","Product"),("Residences on Paper Mill","Product"),("Carlton Crossing","Product"),("The Mabry","Product"),
("The Darnell","Nearby new"),("Lyra by Radius","Nearby new"),("Celadon on Club Apartments","Nearby new"),("District at Parkview","Nearby new")]
# Subject (Yardi = operator truth; Datamart for like-for-like)
S=dict(sqft=2138, yardi_market=2816, yardi_inplace_gross=2734, dm_ask3=2715, dm_ip3=2675.29, new_lease_net_eff=2671, occ_yardi=79/80)
rows=[]
for n,g in SEL:
o=out[n]
sf3=round(o["ask3"]/o["ask3psf"]) if o["ask3"] and o["ask3psf"] else (round(o["ip3"]/o["ip3psf"]) if o["ip3"] and o["ip3psf"] else None)
rows.append(dict(name=n,group=g,dist=o["dist"],units=o["units"],style=o["style"],yb=o["yb"],sf3=sf3,ask3=o["ask3"],ip3=o["ip3"],
ask3psf=o["ask3psf"],ip3psf=o["ip3psf"],occ=o["occ"],ret=o["ret"],ask_t12=o["ask_t12"],ip_t12=o["ip_t12"],dom=o["dom"],id=o["id"]))
for r in rows: print(r)
def stats(key,grp=None):
v=[r[key] for r in rows if r[key] is not None and (grp is None or r["group"]==grp)]
return dict(n=len(v),avg=round(st.mean(v),2),med=round(st.median(v),2),min=min(v),max=max(v))
res={}
for grp in [None,"Product","Nearby new"]:
k=grp or "All"
res[k]={key:stats(key,grp) for key in ["ask3","ip3","ask3psf","ip3psf","sf3","occ","ask_t12","ip_t12"]}
print(json.dumps(res,indent=1))
P=res["Product"]; A=res["All"]
subj_ask_psf=S["yardi_market"]/S["sqft"]; subj_ip_psf=S["yardi_inplace_gross"]/S["sqft"]
print("subject yardi ask psf",round(subj_ask_psf,2),"ip psf",round(subj_ip_psf,2), "dm ask psf", round(S["dm_ask3"]/S["sqft"],2),"dm ip psf",round(S["dm_ip3"]/S["sqft"],2))
for lab,v in [("yardi_market",S["yardi_market"]),("dm_ask3",S["dm_ask3"]),("yardi_inplace",S["yardi_inplace_gross"]),("dm_ip3",S["dm_ip3"]),("net_eff_new",S["new_lease_net_eff"])]:
for grp in ["Product","All"]:
base=res[grp]["ask3" if "ask" in lab or "market" in lab or "net" in lab else "ip3"]["avg"]
print(lab,grp,"vs avg",round(v-base),f"{100*(v/base-1):.1f}%")
# ranks
ask_sorted=sorted([r["ask3"] for r in rows if r["ask3"]]+[S["yardi_market"]],reverse=True)
print("subject Yardi market rent rank among",len(ask_sorted),":",ask_sorted.index(S["yardi_market"])+1)
ip_sorted=sorted([r["ip3"] for r in rows if r["ip3"]]+[S["yardi_inplace_gross"]],reverse=True)
print("subject in-place rank among",len(ip_sorted),":",ip_sorted.index(S["yardi_inplace_gross"])+1)
# Parkview Place direct
pp=out["Parkview Place"]; print("vs Parkview ask",S["yardi_market"]-pp["ask3"],"ip",S["yardi_inplace_gross"]-pp["ip3"])
# Size-adjusted value: product-comp median psf * subject sqft
for key in ["ask3psf","ip3psf"]:
print(key,"product median psf x subject sf =",round(P[key]["med"]*S["sqft"]),"| avg:",round(P[key]["avg"]*S["sqft"]))
# loss to lease
print("Yardi loss-to-lease $",S["yardi_market"]-S["yardi_inplace_gross"], f"{100*(1-S['yardi_inplace_gross']/S['yardi_market']):.1f}%")
# revenue sizing: $ per $25 / $50 increment
for inc in [25,50,75]: print("inc",inc,"annual @79 occ", inc*79*12)
json.dump(dict(rows=rows,stats=res,subject=S),open("extracts/comp_analysis_out.json","w"),indent=1)
EOF
python3 scripts/comp_analysis.pycat > scripts/subject_metrics.py <<'EOF'
# Inputs copied from Yardi query results (lease expirations Oct-2026..Sep-2027; new-lease concessions)
exp={"2026-10":4,"2026-11":5,"2026-12":4,"2027-01":2,"2027-03":7,"2027-04":7,"2027-05":9,"2027-06":14,"2027-07":8,"2027-08":5,"2027-09":3}
tot=sum(exp.values()); peak=sum(exp[m] for m in ["2027-03","2027-04","2027-05","2027-06","2027-07"])
print("expiring next 12m",tot,"Mar-Jul",peak,f"{100*peak/tot:.0f}% of expirations",f"June share of 80 units {100*14/80:.1f}%")
print("concession share of new leases",f"{100*9/55:.0f}%")
print("market rent growth Feb-2023 2560 -> Oct-2026 2816:",f"{100*(2816/2560-1):.1f}%","; Jul-2024 2746 -> Oct-2026:",f"{100*(2816/2746-1):.1f}%")
print("revenue of +$25/+$50 on 79 occupied units / yr:",25*79*12,50*79*12)
EOF
python3 scripts/subject_metrics.pyYour pricing is competitive, and you have room to charge more. Main Street Townes posts the highest 3BR rent in the comp set and is still 98.8% occupied, while comps sit around 93% and are cutting asking rents. You aren't capturing all of that pricing power, though. Posted rent has barely moved since mid-2024, new leases have stopped gaining on the leases they replace, and you're still giving concessions at full occupancy.
One limit on "by bedroom type": all 80 units are 3BR/2.5BA townhomes averaging 2,138 sf , built in 2023 . So 3BR is the only bedroom type to compare.
| Main Street Townes | Townhome/build-to-rent comps (6) | Nearby new apartments (4) | |
|---|---|---|---|
| Asking rent | $2,816 (Yardi market rent) | avg $2,574 | avg $2,528 |
| In-place rent | $2,734 (before concessions) | avg $2,474 | avg $2,524 |
| Occupancy | 79 of 80 units | ~93% | ~93% |
| Property | Group | Distance (mi) | Units | Style | Year Built | 3BR sf (implied) | 3BR Asking | 3BR In-Place | Asking $/sf | In-Place $/sf | Occupancy | T12 Asking Chg |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Subject | — | 80 | Townhouse | 2023 | 2,138 | $2,8161 | $2,734 | $1.32 | $1.28 | 98.8% | — | |
| Townhome / BTR product comps | ||||||||||||
| Product | 4.76 | 147 | Build-for-rent | 2024 | 2,1152 | $2,6233 | $2,489 | $1.24 | $1.26 | 95.2% | -2.3% | |
| Product | 7.20 | 95 | Townhouse | 2023 | 1,833 | $2,713 | $2,586 | $1.48 | $1.41 | 88.4% | +3.3% | |
| Product | 10.18 | 239 | Townhouse | 2024 | 1,635 | $2,796 | $2,807 | $1.71 | $1.72 | 97.5% | -3.2% | |
| Product | 10.76 | 116 | Townhouse | 2023 | 1,433 | $2,264 | $2,172 | $1.58 | $1.51 | 87.9% | -1.3% | |
| Product | 12.07 | 91 | Townhouse | 2024 | 1,868 | $2,429 | $2,214 | $1.30 | $1.18 | 95.6% | -4.5% | |
| Product | 12.89 | 156 | Build-for-rent | 2023 | 1,618 | $2,621 | $2,575 | $1.62 | $1.74 | 93.0% | -3.6% | |
| Product comps average | Product | 9.64 | 141 | — | — | 1,750 | $2,574 | $2,474 | $1.49 | $1.47 | 92.9% | -1.9% |
| Nearby new-construction (≤5 mi) | ||||||||||||
| Nearby new | 2.04 | 246 | Garden | 2023 | 1,420 | —4 | $2,328 | — | $1.64 | 94.3% | -3.1% | |
| Nearby new | 3.73 | 228 | Low-rise | 2022 | 1,402 | $2,748 | $2,758 | $1.96 | $1.97 | 92.1% | -0.4% | |
| Nearby new | 3.91 | 352 | Garden | 2023 | 1,435 | $2,455 | $2,537 | $1.71 | $1.77 | 90.9% | -0.5% | |
| Nearby new | 4.99 | 264 | Low-rise | 2023 | 1,368 | $2,381 | $2,475 | $1.74 | $1.36 | 95.8% | -5.4% | |
| Nearby new average | Nearby new | 3.67 | 273 | — | — | 1,406 | $2,5285 | $2,524 | $1.80 | $1.69 | 93.3% | -2.3% |
How I picked the comps:
Only partly.
Data gap: I couldn't measure how much you've been raising renewals. In Yardi, the renewal lease records carry the same rent as the original lease in every case (49 of 49 in 2026) , which almost certainly means the field is being copied over rather than updated. Pull renewal-offer data from your revenue management system to confirm recommendation 3 before acting on it.
This is a read-only snapshot of a RealAI analysis.