09095a1a32
- 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>
181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
// Static location intelligence data per city — mock values based on Swiss market context
|
||
|
||
export interface CityIntelligence {
|
||
vacancyRatePct: number // Leerstandsquote %
|
||
rentTrend12m: number // Mietpreisveränderung % (letztes Jahr)
|
||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||
dominantIndustryClusters: string[]
|
||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||
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
|
||
taxCalculatorUrl?: string // Kantonaler Steuerrechner
|
||
}
|
||
|
||
export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
|
||
'Zürich': {
|
||
vacancyRatePct: 2.8,
|
||
rentTrend12m: +4.2,
|
||
purchasingPowerIndex: 128,
|
||
dominantIndustryClusters: ['Finanz & Banking', 'Tech & Startups', 'Medien & Kreativ', 'Pharma & Life Science'],
|
||
plannedInfrastructure: [
|
||
{ project: 'Tram Hardbrücke-Verlängerung', timeline: '2027', impact: 'Bessere ÖV-Anbindung Industriequartier' },
|
||
{ project: 'Rosengarten-Tunnel', timeline: '2030', impact: 'Entlastung Kreis 5/6, weniger Durchgangsverkehr' },
|
||
],
|
||
medianRentOffice: 42,
|
||
medianRentLogistics: 14,
|
||
medianRentRetail: 180,
|
||
avgDaysOnMarket: 38,
|
||
demandStrength: 'VERY_HIGH',
|
||
taxIndexCanton: 100,
|
||
taxCalculatorUrl: 'https://www.zh.ch/de/steuern-finanzen/steuerrechner.html',
|
||
},
|
||
'Basel': {
|
||
vacancyRatePct: 4.1,
|
||
rentTrend12m: +1.8,
|
||
purchasingPowerIndex: 112,
|
||
dominantIndustryClusters: ['Pharma & Chemie', 'Logistik & Handel', 'Medizintechnik', 'Finanzdienstleistungen'],
|
||
plannedInfrastructure: [
|
||
{ project: 'Basel SBB Südeingang Neubau', timeline: '2026', impact: 'Aufwertung Bahnhofumgebung' },
|
||
{ project: 'Regio-S-Bahn Ausbau', timeline: '2028', impact: 'Bessere Grenzpendler-Anbindung' },
|
||
],
|
||
medianRentOffice: 32,
|
||
medianRentLogistics: 11,
|
||
medianRentRetail: 120,
|
||
avgDaysOnMarket: 52,
|
||
demandStrength: 'HIGH',
|
||
taxIndexCanton: 98,
|
||
taxCalculatorUrl: 'https://www.steuern.bs.ch/steuerrechner',
|
||
},
|
||
'Bern': {
|
||
vacancyRatePct: 3.5,
|
||
rentTrend12m: +2.1,
|
||
purchasingPowerIndex: 108,
|
||
dominantIndustryClusters: ['Bundesverwaltung & NPO', 'Gesundheit', 'Bildung & Forschung', 'Versicherungen'],
|
||
plannedInfrastructure: [
|
||
{ project: 'Bernmobil Netzausbau West', timeline: '2026', impact: 'Erschliessung Entwicklungsgebiet Ausserholligen' },
|
||
],
|
||
medianRentOffice: 28,
|
||
medianRentLogistics: 10,
|
||
medianRentRetail: 95,
|
||
avgDaysOnMarket: 61,
|
||
demandStrength: 'MEDIUM',
|
||
taxIndexCanton: 112,
|
||
taxCalculatorUrl: 'https://www.taxme.ch',
|
||
},
|
||
'Zug': {
|
||
vacancyRatePct: 1.9,
|
||
rentTrend12m: +5.1,
|
||
purchasingPowerIndex: 148,
|
||
dominantIndustryClusters: ['Rohstoffhandel', 'Crypto & Blockchain', 'Holding & Finanzen', 'Tech-Unternehmen'],
|
||
plannedInfrastructure: [
|
||
{ project: 'Metrobahn Zug-Luzern', timeline: '2029', impact: 'Direktverbindung Luzern in 18 Min.' },
|
||
],
|
||
medianRentOffice: 38,
|
||
medianRentLogistics: 13,
|
||
medianRentRetail: 140,
|
||
avgDaysOnMarket: 24,
|
||
demandStrength: 'VERY_HIGH',
|
||
taxIndexCanton: 60,
|
||
taxCalculatorUrl: 'https://www.zg.ch/behoerden/finanzdirektion/steuerverwaltung/steuerrechner',
|
||
},
|
||
'Winterthur': {
|
||
vacancyRatePct: 5.8,
|
||
rentTrend12m: +0.9,
|
||
purchasingPowerIndex: 98,
|
||
dominantIndustryClusters: ['Industrie & Maschinenbau', 'Logistik', 'Gesundheit & Soziales'],
|
||
plannedInfrastructure: [
|
||
{ project: 'Stadtraum HB Winterthur', timeline: '2027', impact: 'Aufwertung Bahnhofsumgebung, mehr Frequenz' },
|
||
],
|
||
medianRentOffice: 22,
|
||
medianRentLogistics: 9,
|
||
medianRentRetail: 75,
|
||
avgDaysOnMarket: 74,
|
||
demandStrength: 'MEDIUM',
|
||
taxIndexCanton: 119,
|
||
taxCalculatorUrl: 'https://www.zh.ch/de/steuern-finanzen/steuerrechner.html',
|
||
},
|
||
'Geneva': {
|
||
vacancyRatePct: 2.2,
|
||
rentTrend12m: +3.6,
|
||
purchasingPowerIndex: 135,
|
||
dominantIndustryClusters: ['Internationale Organisationen', 'Luxusgüter', 'Banking & Private Equity', 'Uhrenindustrie'],
|
||
plannedInfrastructure: [
|
||
{ project: 'CEVA Linie Verlängerung', timeline: '2026', impact: 'Bessere Verbindung Lancy-Pont-Rouge' },
|
||
],
|
||
medianRentOffice: 55,
|
||
medianRentLogistics: 18,
|
||
medianRentRetail: 220,
|
||
avgDaysOnMarket: 31,
|
||
demandStrength: 'HIGH',
|
||
taxIndexCanton: 125,
|
||
taxCalculatorUrl: 'https://www.ge.ch/calculer-impots',
|
||
},
|
||
'St.Gallen': {
|
||
vacancyRatePct: 6.2,
|
||
rentTrend12m: -0.5,
|
||
purchasingPowerIndex: 95,
|
||
dominantIndustryClusters: ['Textil & Mode', 'KMU', 'Logistik', 'Gesundheit'],
|
||
plannedInfrastructure: [],
|
||
medianRentOffice: 19,
|
||
medianRentLogistics: 8,
|
||
medianRentRetail: 65,
|
||
avgDaysOnMarket: 88,
|
||
demandStrength: 'LOW',
|
||
taxIndexCanton: 107,
|
||
taxCalculatorUrl: 'https://www.steuern.sg.ch/home/steuerrechner.html',
|
||
},
|
||
}
|
||
|
||
export function getCityIntelligence(city: string): CityIntelligence | null {
|
||
// Try exact match first, then partial
|
||
if (CITY_INTELLIGENCE[city]) return CITY_INTELLIGENCE[city]
|
||
const key = Object.keys(CITY_INTELLIGENCE).find(k => city.toLowerCase().includes(k.toLowerCase()))
|
||
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
|
||
const monthly =
|
||
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
|
||
: assetType === 'RETAIL' ? intel.medianRentRetail
|
||
: intel.medianRentOffice
|
||
return monthly * 12
|
||
}
|
||
|
||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||
|
||
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
|
||
'Zürich': { lat: 47.3769, lng: 8.5417 },
|
||
'Zürich-West': { lat: 47.3862, lng: 8.5195 },
|
||
'Zürich-Nord': { lat: 47.4108, lng: 8.5450 },
|
||
'Basel': { lat: 47.5596, lng: 7.5886 },
|
||
'Bern': { lat: 46.9480, lng: 7.4474 },
|
||
'Luzern': { lat: 47.0502, lng: 8.3093 },
|
||
'Genf': { lat: 46.2044, lng: 6.1432 },
|
||
'Lausanne': { lat: 46.5197, lng: 6.6323 },
|
||
'Zug': { lat: 47.1661, lng: 8.5158 },
|
||
'Winterthur': { lat: 47.5004, lng: 8.7256 },
|
||
'St. Gallen': { lat: 47.4245, lng: 9.3767 },
|
||
'Lugano': { lat: 46.0037, lng: 8.9511 },
|
||
'Baar': { lat: 47.1965, lng: 8.5286 },
|
||
'Muttenz': { lat: 47.5218, lng: 7.6468 },
|
||
'Pratteln': { lat: 47.5163, lng: 7.6926 },
|
||
'Reinach BL': { lat: 47.4948, lng: 7.5946 },
|
||
'Allschwil': { lat: 47.5529, lng: 7.5368 },
|
||
'Binningen': { lat: 47.5363, lng: 7.5687 },
|
||
}
|
||
|
||
export function lookupCityCoords(location: string): { lat: number; lng: number } | null {
|
||
if (CITY_COORDS[location]) return CITY_COORDS[location]
|
||
// Prefix fallback: "Zürich Kreis 5" → "Zürich"
|
||
const base = location.split(' ')[0]
|
||
const key = Object.keys(CITY_COORDS).find(k => k.startsWith(base) || base === k)
|
||
return key ? CITY_COORDS[key] : null
|
||
}
|