fix(ai): realistic pre-market rent recommendation — annual units + anchor on current price
- getMarketRent now returns CHF/m²/YEAR (stored medians are monthly → ×12); fixes annual-vs-monthly mismatch that also affected MarketPricePanel and NegotiationInsightsPanel - recommendPreMarketRent: anchor on the unit's current rent and adjust within a bounded ±15% by market momentum (vacancy=supply, demand strength, days-on-market, trend) instead of jumping to the city-wide (prime-skewed) median — no more "double the price" suggestions; median only used as a headroom check - tests updated to annual market values Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,28 +2,28 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
|
||||||
|
|
||||||
describe('estimateMarketRent', () => {
|
describe('estimateMarketRent', () => {
|
||||||
|
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
|
||||||
it('flags asking rent clearly above the city median as ABOVE', () => {
|
it('flags asking rent clearly above the city median as ABOVE', () => {
|
||||||
// Zürich OFFICE median = 42
|
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 60)
|
|
||||||
expect(est).not.toBeNull()
|
expect(est).not.toBeNull()
|
||||||
expect(est!.verdict).toBe('ABOVE')
|
expect(est!.verdict).toBe('ABOVE')
|
||||||
expect(est!.deltaPct).toBeGreaterThan(5)
|
expect(est!.deltaPct).toBeGreaterThan(5)
|
||||||
expect(est!.fairRentPerSqm).toBe(42)
|
expect(est!.fairRentPerSqm).toBe(504)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flags asking rent clearly below median as BELOW', () => {
|
it('flags asking rent clearly below median as BELOW', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 30)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
|
||||||
expect(est!.verdict).toBe('BELOW')
|
expect(est!.verdict).toBe('BELOW')
|
||||||
expect(est!.deltaPct).toBeLessThan(-5)
|
expect(est!.deltaPct).toBeLessThan(-5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('treats near-median asking rent as AT (within ±5%)', () => {
|
it('treats near-median asking rent as AT (within ±5%)', () => {
|
||||||
const est = estimateMarketRent('Zürich', 'OFFICE', 43)
|
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
|
||||||
expect(est!.verdict).toBe('AT')
|
expect(est!.verdict).toBe('AT')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null for unknown city', () => {
|
it('returns null for unknown city', () => {
|
||||||
expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull()
|
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suggestFutureRent indexes by the city rent trend', () => {
|
it('suggestFutureRent indexes by the city rent trend', () => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export interface CityIntelligence {
|
|||||||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||||||
dominantIndustryClusters: string[]
|
dominantIndustryClusters: string[]
|
||||||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||||||
medianRentOffice: number // CHF/m² für Bürofläche
|
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
|
||||||
medianRentLogistics: number
|
medianRentLogistics: number // CHF/m²/Monat
|
||||||
medianRentRetail: number
|
medianRentRetail: number // CHF/m²/Monat
|
||||||
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
||||||
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||||
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
||||||
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
|
|||||||
return key ? CITY_INTELLIGENCE[key] : null
|
return key ? CITY_INTELLIGENCE[key] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
|
||||||
export function getMarketRent(city: string, assetType: string): number | null {
|
export function getMarketRent(city: string, assetType: string): number | null {
|
||||||
const intel = getCityIntelligence(city)
|
const intel = getCityIntelligence(city)
|
||||||
if (!intel) return null
|
if (!intel) return null
|
||||||
if (assetType === 'OFFICE') return intel.medianRentOffice
|
const monthly =
|
||||||
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
|
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
|
||||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
: assetType === 'RETAIL' ? intel.medianRentRetail
|
||||||
return intel.medianRentOffice
|
: intel.medianRentOffice
|
||||||
|
return monthly * 12
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -364,38 +364,41 @@ export const MockAIService: IAIService = {
|
|||||||
return { data, provenance: mockProvenance() }
|
return { data, provenance: mockProvenance() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Angebot/Nachfrage-Anpassung auf die regionale Vergleichsmiete
|
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
|
||||||
let adj = 0
|
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
|
||||||
if (intel.vacancyRatePct < 2.5) adj += 0.06
|
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
|
||||||
|
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
|
||||||
|
if (intel.vacancyRatePct < 2.5) adj += 0.04
|
||||||
else if (intel.vacancyRatePct < 4) adj += 0.02
|
else if (intel.vacancyRatePct < 4) adj += 0.02
|
||||||
else if (intel.vacancyRatePct > 5.5) adj -= 0.06
|
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
|
||||||
else if (intel.vacancyRatePct > 4.5) adj -= 0.03
|
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
|
||||||
adj += { VERY_HIGH: 0.06, HIGH: 0.03, MEDIUM: 0, LOW: -0.05 }[intel.demandStrength]
|
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
|
||||||
if (intel.avgDaysOnMarket < 35) adj += 0.02
|
if (intel.avgDaysOnMarket < 35) adj += 0.015
|
||||||
else if (intel.avgDaysOnMarket > 75) adj -= 0.03
|
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
|
||||||
const trendFwd = intel.rentTrend12m / 100 // Pre-Market liegt in der Zukunft → Trend vorwärts
|
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
|
||||||
|
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
|
||||||
|
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: −10 % … +15 %
|
||||||
|
|
||||||
const recommended = Math.round(comp * (1 + adj + trendFwd))
|
const recommended = Math.round(current * (1 + adj))
|
||||||
const rangeMin = Math.round(recommended * 0.93)
|
const rangeMin = Math.round(recommended * 0.95)
|
||||||
const rangeMax = Math.round(recommended * 1.07)
|
const rangeMax = Math.round(recommended * 1.05)
|
||||||
const deltaVsCurrentPct = Math.round(((recommended - current) / current) * 100)
|
const deltaVsCurrentPct = Math.round(adj * 100)
|
||||||
const verdict: PreMarketRentRecommendation['verdict'] =
|
const verdict: PreMarketRentRecommendation['verdict'] =
|
||||||
deltaVsCurrentPct >= 6 ? 'UNDERPRICED' : deltaVsCurrentPct <= -6 ? 'AMBITIOUS' : 'FAIR'
|
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
|
||||||
|
|
||||||
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
|
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
|
||||||
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
|
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
|
||||||
const drivers = [
|
const drivers = [
|
||||||
`Vergleichsmiete Region: CHF ${comp}/m²`,
|
|
||||||
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
|
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
|
||||||
demandLabel,
|
demandLabel,
|
||||||
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
|
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
|
||||||
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
|
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
|
||||||
]
|
]
|
||||||
const verdictText =
|
const verdictText =
|
||||||
verdict === 'UNDERPRICED' ? `Ihr heutiger Preis (CHF ${current}/m²) liegt ${Math.abs(deltaVsCurrentPct)}% unter der Empfehlung — klarer Spielraum nach oben.`
|
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
|
||||||
: verdict === 'AMBITIOUS' ? `Ihr heutiger Preis liegt ${Math.abs(deltaVsCurrentPct)}% über der Markteinschätzung — ambitioniert.`
|
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
|
||||||
: 'Ihr heutiger Preis ist marktgerecht.'
|
: 'Heutiger Preis ist marktgerecht.'
|
||||||
const rationale = `Auf Basis vergleichbarer ${assetLabel} in ${input.city} (Median CHF ${comp}/m²), ${supplyLabel} und ${demandLabel}. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}). ${verdictText}`
|
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
|
||||||
const confidence: PreMarketRentRecommendation['confidence'] =
|
const confidence: PreMarketRentRecommendation['confidence'] =
|
||||||
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
|
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user