diff --git a/src/lib/__tests__/rentEstimate.test.ts b/src/lib/__tests__/rentEstimate.test.ts index 6425c99..3db5eb7 100644 --- a/src/lib/__tests__/rentEstimate.test.ts +++ b/src/lib/__tests__/rentEstimate.test.ts @@ -2,28 +2,28 @@ import { describe, it, expect } from 'vitest' import { estimateMarketRent, suggestFutureRent } from '../rentEstimate' describe('estimateMarketRent', () => { + // Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12) it('flags asking rent clearly above the city median as ABOVE', () => { - // Zürich OFFICE median = 42 - const est = estimateMarketRent('Zürich', 'OFFICE', 60) + const est = estimateMarketRent('Zürich', 'OFFICE', 600) expect(est).not.toBeNull() expect(est!.verdict).toBe('ABOVE') expect(est!.deltaPct).toBeGreaterThan(5) - expect(est!.fairRentPerSqm).toBe(42) + expect(est!.fairRentPerSqm).toBe(504) }) 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!.deltaPct).toBeLessThan(-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') }) 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', () => { diff --git a/src/lib/locationIntelligence.ts b/src/lib/locationIntelligence.ts index 0dc63ab..71d04d8 100644 --- a/src/lib/locationIntelligence.ts +++ b/src/lib/locationIntelligence.ts @@ -6,9 +6,9 @@ export interface CityIntelligence { purchasingPowerIndex: number // Kaufkraft-Index (CH = 100) dominantIndustryClusters: string[] plannedInfrastructure: { project: string; timeline: string; impact: string }[] - medianRentOffice: number // CHF/m² für Bürofläche - medianRentLogistics: number - medianRentRetail: number + medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um + medianRentLogistics: number // CHF/m²/Monat + medianRentRetail: number // CHF/m²/Monat avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH' taxIndexCanton: number // Steuerindex 100 = CH-Mittel @@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | 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 { const intel = getCityIntelligence(city) if (!intel) return null - if (assetType === 'OFFICE') return intel.medianRentOffice - if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics - if (assetType === 'RETAIL') return intel.medianRentRetail - return intel.medianRentOffice + const monthly = + assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics + : assetType === 'RETAIL' ? intel.medianRentRetail + : intel.medianRentOffice + return monthly * 12 } // ── City coordinates (WGS84) ───────────────────────────────────────────────── diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 4615938..3a5ab5a 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -364,38 +364,41 @@ export const MockAIService: IAIService = { return { data, provenance: mockProvenance() } } - // Angebot/Nachfrage-Anpassung auf die regionale Vergleichsmiete - let adj = 0 - if (intel.vacancyRatePct < 2.5) adj += 0.06 + // Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt + // nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den + // 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 > 5.5) adj -= 0.06 - else if (intel.vacancyRatePct > 4.5) adj -= 0.03 - adj += { VERY_HIGH: 0.06, HIGH: 0.03, MEDIUM: 0, LOW: -0.05 }[intel.demandStrength] - if (intel.avgDaysOnMarket < 35) adj += 0.02 - else if (intel.avgDaysOnMarket > 75) adj -= 0.03 - const trendFwd = intel.rentTrend12m / 100 // Pre-Market liegt in der Zukunft → Trend vorwärts + else if (intel.vacancyRatePct > 5.5) adj -= 0.05 + else if (intel.vacancyRatePct > 4.5) adj -= 0.02 + adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength] + if (intel.avgDaysOnMarket < 35) adj += 0.015 + else if (intel.avgDaysOnMarket > 75) adj -= 0.025 + // 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 rangeMin = Math.round(recommended * 0.93) - const rangeMax = Math.round(recommended * 1.07) - const deltaVsCurrentPct = Math.round(((recommended - current) / current) * 100) + const recommended = Math.round(current * (1 + adj)) + const rangeMin = Math.round(recommended * 0.95) + const rangeMax = Math.round(recommended * 1.05) + const deltaVsCurrentPct = Math.round(adj * 100) 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 demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength] const drivers = [ - `Vergleichsmiete Region: CHF ${comp}/m²`, `Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`, demandLabel, `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`, `Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`, ] const verdictText = - verdict === 'UNDERPRICED' ? `Ihr heutiger Preis (CHF ${current}/m²) liegt ${Math.abs(deltaVsCurrentPct)}% unter der Empfehlung — klarer Spielraum nach oben.` - : verdict === 'AMBITIOUS' ? `Ihr heutiger Preis liegt ${Math.abs(deltaVsCurrentPct)}% über der Markteinschätzung — ambitioniert.` - : 'Ihr 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}` + verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).` + : verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.` + : 'Heutiger Preis ist marktgerecht.' + 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'] = intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'