Forecast rent growth in Los Angeles, San Diego, and San Fransisco. What market has the most attractive investment opportunity
cat /vercel/sandbox/skills/forecasting/references/request.md | head -200
mkdir -p /vercel/sandbox/fc && cat > /vercel/sandbox/fc/build_payloads.py << 'EOF'
import json
# Raw monthly series pulled from mf_rent_ts (period_type MONTH) for each market
# format: (period_start_date, asking_rent_latest_avg, in_place_rent_latest_avg)
la_raw = [
("2024-07-01",3186.770172,2671.924271),
("2024-08-01",3167.139244,2692.391143),
("2024-09-01",3141.201640,2705.865999),
("2024-10-01",3106.109694,2713.993440),
("2024-11-01",3080.836666,2707.638628),
("2024-12-01",3061.610470,2716.137176),
("2025-01-01",3001.674183,2738.833992),
("2025-02-01",3003.672369,2747.419845),
("2025-03-01",2999.735460,2751.769125),
("2025-04-01",2998.126479,2759.494479),
("2025-05-01",2982.683933,2770.763584),
("2025-06-01",2989.044651,2779.027289),
("2025-07-01",3006.163994,2785.690931),
("2025-08-01",2997.831525,2791.532396),
("2025-09-01",2993.976772,2791.952268),
("2025-10-01",3040.374174,2788.026548),
("2025-11-01",3057.135878,2788.137621),
("2025-12-01",3064.325943,2787.766942),
("2026-01-01",3096.932916,2788.428572),
("2026-02-01",3057.085692,2788.945028),
("2026-03-01",3036.712522,2790.687386),
("2026-04-01",3026.557115,2786.639446),
("2026-05-01",3055.842320,2788.227284),
("2026-06-01",3083.809593,2791.293276),
("2026-07-01",3091.842972,2798.276639),
("2026-08-01",3092.786486,2801.520166),
]
sd_raw = [
("2024-07-01",3228.850000,2845.428015),
("2024-08-01",3201.811082,2853.846655),
("2024-09-01",3195.688042,2864.952843),
("2024-10-01",3144.988069,2865.972254),
("2024-11-01",3116.949513,2863.159043),
("2024-12-01",3102.464613,2861.026584),
("2025-01-01",3099.746418,2857.268342),
("2025-02-01",3081.180086,2863.148358),
("2025-03-01",3066.286191,2865.532390),
("2025-04-01",3069.701792,2865.482531),
("2025-05-01",3045.851126,2866.155698),
("2025-06-01",3058.799644,2868.855569),
("2025-07-01",3058.957825,2872.012853),
("2025-08-01",3082.706393,2870.872415),
("2025-09-01",3069.993173,2877.035058),
("2025-10-01",3127.182673,2870.814240),
("2025-11-01",3119.750684,2867.102616),
("2025-12-01",3118.140087,2866.072514),
("2026-01-01",3137.355596,2869.285334),
("2026-02-01",3126.881134,2869.508619),
("2026-03-01",3113.150460,2870.882704),
("2026-04-01",3187.646695,2874.346933),
("2026-05-01",3205.699346,2877.189242),
("2026-06-01",3216.902553,2882.825459),
("2026-07-01",3218.850634,2891.514593),
("2026-08-01",3256.040649,2897.170591),
]
sf_raw = [
("2024-07-01",3106.149358,2896.885844),
("2024-08-01",3065.849538,2906.459989),
("2024-09-01",3023.895915,2912.853180),
("2024-10-01",2993.998720,2917.298384),
("2024-11-01",2977.367010,2923.792046),
("2024-12-01",2987.572420,2923.070177),
("2025-01-01",2991.340995,2923.212461),
("2025-02-01",2980.509523,2929.531733),
("2025-03-01",3008.824688,2938.122096),
("2025-04-01",3003.998770,2943.854190),
("2025-05-01",3040.068697,2954.776120),
("2025-06-01",3060.850138,2961.716275),
("2025-07-01",3080.083304,2972.827599),
("2025-08-01",3094.825285,2979.694986),
("2025-09-01",3108.103462,2983.923635),
("2025-10-01",3103.390390,2987.341086),
("2025-11-01",3089.992114,2991.625868),
("2025-12-01",3084.835485,2996.227795),
("2026-01-01",3082.839778,3004.408568),
("2026-02-01",3076.257989,3010.603151),
("2026-03-01",3074.067133,3020.664503),
("2026-04-01",3087.239746,3032.841821),
("2026-05-01",3122.617036,3046.941605),
("2026-06-01",3217.681780,3059.929064),
("2026-07-01",3370.620761,3081.703238),
("2026-08-01",3489.550590,3096.493516),
]
def sibling_divergence(raw):
# trailing 12mo growth gap between asking and in-place
asking_now, inplace_now = raw[-1][1], raw[-1][2]
asking_12ago, inplace_12ago = raw[-13][1], raw[-13][2]
asking_g = asking_now/asking_12ago - 1
inplace_g = inplace_now/inplace_12ago - 1
return asking_g*100, inplace_g*100, (asking_g-inplace_g)*100
for name, raw in [("LA", la_raw), ("SD", sd_raw), ("SF", sf_raw)]:
ag, ig, div = sibling_divergence(raw)
print(name, "asking T12%:", round(ag,2), "inplace T12%:", round(ig,2), "divergence bps:", round(div*100,1))
json.dump({"la":la_raw,"sd":sd_raw,"sf":sf_raw}, open("/vercel/sandbox/fc/raw.json","w"))
EOF
python3 /vercel/sandbox/fc/build_payloads.pycat > /vercel/sandbox/fc/run_all.py << 'EOF'
import json, subprocess
raw = json.load(open("/vercel/sandbox/fc/raw.json"))
def hist(raw_list, idx):
return [{"period": p, "value": round(v[idx-1],2), "source":"datamart"} for p,*v in raw_list]
def check(raw_list, idx):
vals = [v[idx-1] for p,*v in raw_list]
return {"count": len(vals), "sum": round(sum(vals),2), "first_value": round(vals[0],2), "last_value": round(vals[-1],2)}
markets = {
"LA": {
"raw": raw["la"], "entity_id":"31080", "label":"Los Angeles, CA (MSA)",
"existing_stock":616462, "uc_t12":19309, "permit_t13_t24":14684,
"inbound_income":129420, "outbound_income":137188, "job_growth":0.0177
},
"SD": {
"raw": raw["sd"], "entity_id":"41740", "label":"San Diego, CA (MSA)",
"existing_stock":210727, "uc_t12":7549, "permit_t13_t24":8848,
"inbound_income":135501, "outbound_income":136868, "job_growth":0.0049
},
"SF": {
"raw": raw["sf"], "entity_id":"41860", "label":"San Francisco, CA (MSA)",
"existing_stock":228275, "uc_t12":3644, "permit_t13_t24":4535,
"inbound_income":221691, "outbound_income":230353, "job_growth":0.0228
},
}
results = {}
for mkey, m in markets.items():
for metric_key, idx, field_label in [("asking",1,"mf_rent_ts.asking_rent_latest_avg"), ("inplace",2,"mf_rent_ts.in_place_rent_latest_avg")]:
payload = {
"metric": {"name": field_label, "units":"$", "family":"rent_or_occupancy"},
"subject": {"entity_type":"market", "entity_id": m["entity_id"], "label": m["label"]},
"horizon": {"years":3, "intervals":"annual"},
"as_of": "2026-09-11",
"requested_lookback_years": 2.2,
"caller_disclosures": {
"peer_omitted_reason": "not gathered",
"sibling_series_note": "asking vs in-place checked; divergence >100bps, both run",
"lookback_note": "full available monthly history (26 valid points, 2024-07 to 2026-08)"
},
"history": hist(m["raw"], idx),
"history_check": check(m["raw"], idx),
"context_signals": {
"supply_pipeline": {
"existing_stock": m["existing_stock"],
"under_construction_t12": m["uc_t12"],
"permitted_units_t13_t24": m["permit_t13_t24"]
},
"migration": {
"inbound_income": m["inbound_income"],
"outbound_income": m["outbound_income"]
},
"employment": {
"job_growth_1_year_pct": m["job_growth"]
}
},
"scenarios": ["base","upside","downside"]
}
fname = f"/vercel/sandbox/fc/payload_{mkey}_{metric_key}.json"
json.dump(payload, open(fname,"w"))
out = subprocess.run(["python3","skills/forecasting/scripts/forecast.py","--file",fname], cwd="/vercel/sandbox", capture_output=True, text=True)
if out.returncode != 0:
print(mkey, metric_key, "ERROR", out.stderr[:2000])
else:
results[f"{mkey}_{metric_key}"] = json.loads(out.stdout)
json.dump(results, open("/vercel/sandbox/fc/results.json","w"), indent=2)
for k,v in results.items():
print(k, v.get("status"), v.get("confidence"), [c.get("name") for c in v.get("data_quality_flags",[])] if v.get("data_quality_flags") else v.get("data_quality_flags"))
EOF
python3 /vercel/sandbox/fc/run_all.pycd /vercel/sandbox/fc && python3 -c "
import json
r = json.load(open('results.json'))
for k,v in r.items():
print(k, v.get('status'), v.get('confidence'), v.get('data_quality_flags'))
"cd /vercel/sandbox/fc && python3 -c "
import json
r = json.load(open('results.json'))
for k,v in r.items():
print('---',k)
print('base_case', v.get('base_case'))
print('upside_case', v.get('upside_case'))
print('downside_case', v.get('downside_case'))
"cd /vercel/sandbox/fc && python3 -c "
import json
r = json.load(open('results.json'))
for k in ['LA_asking','SD_asking','SF_asking','LA_inplace','SD_inplace','SF_inplace']:
v=r[k]
base=v['base_case']
y3=base[-1]['value']
# cumulative from last history point
hist_last = json.load(open(f'payload_{k}.json' if False else '/dev/null')) if False else None
print('cumulative 3yr base case growth from current level:')
starts = {'LA_asking':3092.79,'SD_asking':3256.04,'SF_asking':3489.55,'LA_inplace':2801.52,'SD_inplace':2897.17,'SF_inplace':3096.49}
for k in ['LA_asking','SD_asking','SF_asking','LA_inplace','SD_inplace','SF_inplace']:
v=r[k]['base_case'][-1]['value']
s=starts[k]
print(k, 'start',s,'y3',v,'cum%',round((v/s-1)*100,1))
"
San Diego is the best risk-adjusted rent-growth bet of the three — San Francisco is reaccelerating fastest but at a fragile, supply-starved extreme, and Los Angeles is discounted for a reason.
Market posture: LA — Weakening. San Diego — Holding, improving. San Francisco — Accelerating, but stretched.
| Metric | |||
|---|---|---|---|
| Asking rent, T12 change | +1.1% | -0.7% | +6.5% |
| 3yr asking rent growth forecast, base case | -0.8%1 | +4.9%1 | +11.7%1 |
| New-lease tradeout % | 1.7% | 1.5% | 15.4% |
| Occupancy, latest | 95.8% | 95.4% | 97.3% |
| Occupancy, 12-month change | -1.3 pts | -1.6 pts | +0.1 pts |
| MF vacancy rate | 5.8% | 7.2% | 3.7% |
| Under-construction, % of stock | 3.1% | 3.6% | 1.6% |
| Multifamily permits, T12 | 22.0k | 7.2k | 5.3k |
| Multifamily permits, T13-24 | 14.7k | 8.8k | 4.5k |
| Permit trend | Accelerating | Decelerating | Accelerating |
| Rent-to-income ratio | 41.9% (Severe) | 40.0% (Severe) | 32.2% (Moderate) |
| NOI margin, % of EGI | 57.0% | 59.6% | 53.9% |
| MF cap rate, 2Q26 | 5.19% | 4.93% | 4.71% |
| Net migration, % of population | -0.81%2 | -0.62%2 | -0.59%2 |
Where rents are headed. The forecast splits the three cleanly. LA's asking rent is going nowhere — the engine's own drawdown guard fired because the trailing trend is negative (occupancy down 1.3pt over 12 months, tradeouts near zero to negative in four of the last six months) and it refused to extrapolate a multi-year recovery without more history . San Diego's tradeouts are rebuilding (up sharply the last quarter after a soft second half of 2025) and the model puts it on a modest +4.9% cumulative 3-year path . San Francisco is the outlier: asking rent is up 10.5% in the last three months alone and tradeouts have gone from ~5% a year ago to 15.4% now — the engine flagged this as possible "boom extrapolation" (its entire history sits inside one post-2020 regime) and capped confidence at medium, so treat the +11.7% base case as directionally right, not a number to underwrite to without independent confirmation of the AI/return-to-office demand story driving it.
What supply is doing. This is the variable that separates the three. SF has delivered zero new MF units year-to-date and carries only 1.6% of stock under construction — the tightest supply picture of any major market, which is exactly why tradeouts are spiking . SD's pipeline is actually decelerating (permits down from 8,848 to 7,168 across the two trailing 12-month windows), which is why its rent recovery, though modest, looks durable rather than borrowed. LA is the opposite: permits are accelerating (14,684 → 21,985) on top of the largest under-construction pipeline of the three, arriving into a market where occupancy is already softening — a timing problem stacked on a demand problem.
The investment read. All three markets are losing population on net and none screens as a demand story — this is a pricing-and-supply argument, not a growth-market argument. LA's 5.19% cap rate is the "cheapest" of the three, but it's cheap because rent is flat, vacancy is rising, and rent-to-income is already at 41.9% — a genuinely maxed-out tenant base with a supply wave still coming. SF is priced tightest (4.71% cap, lowest NOI margin) and its momentum is real but concentrated in the top of a fragile, one-regime trend — you're paying up for growth the model itself won't fully vouch for. San Diego is the market where the numbers agree with each other: cap rate in the middle (4.93%), the best operating margin of the three (59.6% NOI/EGI), a decelerating pipeline, and a rent-growth forecast that didn't need a guard to hold it up. That combination — real, engine-confirmed growth at a fair basis with the best margin — is the more investable setup than SF's steeper but shakier curve or LA's discount that's a discount for a reason.
Act on this now: underwrite San Diego multifamily as the entry point among the three, sizing rent growth to the ~5% base case rather than SF's headline trajectory. What would flip it: if SD's next quarter of tradeout data reverses back negative — the improvement is only one quarter deep — the "durable recovery" read collapses and LA's cheaper basis becomes the better relative value instead.
Happy to go deeper on submarket-level dispersion within any of the three, or run the SF trajectory against a corroborating peer basket to stress-test the boom-extrapolation flag.
This is a read-only snapshot of a RealAI analysis.