From fca113e7ab42345009d16c013e2b411f69d9fb42 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 20 Jun 2026 23:53:00 +0200 Subject: [PATCH] feat(demand): market rent estimate (asking vs. fair) + pre-market expected price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/rentEstimate: deterministic market estimate from Standort-Intelligence (median rent benchmark + vacancy/trend) → fair rent, verdict BELOW/AT/ABOVE, deltaPct, rationale; suggestFutureRent indexes today's price by city rent trend - MarketPricePanel in MatchDetail: asking vs. fair market rent + verdict badge + rationale; shows pre-market expected price (heute → erwartet) - unit.expectedRentPerSqm: future pre-market price; scorer prefers it over current rent for FUTURE_AVAILABILITY - UnitStructurePanel editor: expected-price field (with indexed suggestion) shown for pre-market-released units - tests: rentEstimate verdict thresholds + future-rent indexing Note: rent estimate is a deterministic market-data calc (not an LLM) — honest & testable; can be routed through IAIService later if a real model is wanted. Co-Authored-By: Claude Opus 4.8 --- .../match-detail/MarketPricePanel.tsx | 70 +++++++++++++++++++ src/components/match-detail/index.ts | 1 + src/components/supply/UnitStructurePanel.tsx | 19 +++++ src/domain/unit.ts | 1 + src/lib/__tests__/rentEstimate.test.ts | 33 +++++++++ src/lib/rentEstimate.ts | 42 +++++++++++ src/pages/demand/MatchDetail.tsx | 13 +++- src/services/matchSyncService.ts | 4 +- 8 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/components/match-detail/MarketPricePanel.tsx create mode 100644 src/lib/__tests__/rentEstimate.test.ts create mode 100644 src/lib/rentEstimate.ts diff --git a/src/components/match-detail/MarketPricePanel.tsx b/src/components/match-detail/MarketPricePanel.tsx new file mode 100644 index 0000000..ade4cd9 --- /dev/null +++ b/src/components/match-detail/MarketPricePanel.tsx @@ -0,0 +1,70 @@ +import { Box, Chip, Paper, Typography } from '@mui/material' +import { TrendingUp } from 'lucide-react' +import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate' +import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds' + +const VERDICT_META: Record = { + BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success }, + AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark }, + ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning }, +} + +interface Props { + city: string + assetType: string + askingRentPerSqm: number + futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis +} + +function chf(v: number): string { + return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²` +} + +export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) { + const est = estimateMarketRent(city, assetType, askingRentPerSqm) + if (!est) return null + const meta = VERDICT_META[est.verdict] + + return ( + + + + Marktpreis-Einschätzung + + + + + + Angebotsmiete + {chf(est.askingRentPerSqm)} + + + Faire Marktmiete + + {chf(est.fairRentPerSqm)} + + {est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`} + + + + + + {futureRentPerSqm != null && futureRentPerSqm > 0 && ( + + Erwartet (Pre-Market) + + {chf(est.askingRentPerSqm)} {chf(futureRentPerSqm)} + + + )} + + + {est.rationale} + + + ) +} diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts index de3be04..297994a 100644 --- a/src/components/match-detail/index.ts +++ b/src/components/match-detail/index.ts @@ -14,4 +14,5 @@ export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel export { NextActionsPanel } from './NextActionsPanel' export { FitOutCostPanel } from './FitOutCostPanel' export { FitOutAdvicePanel } from './FitOutAdvicePanel' +export { MarketPricePanel } from './MarketPricePanel' export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index ee7ffe4..418506a 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -8,6 +8,7 @@ import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/ import { useUpdateUnit } from '../../hooks/useProperties' import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { FIT_OUT_LABELS } from '../../lib/constants' +import { suggestFutureRent } from '../../lib/rentEstimate' import { floorLabel } from './PropertyDetailHelpers' const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC']) @@ -19,6 +20,7 @@ interface UnitDraft { fitOutByLandlord?: boolean mieterausbaubeitragPerSqm?: number parkingSpots?: number + expectedRentPerSqm?: number } function MatchPill({ m }: { m: UnitNeedMatch }) { @@ -74,6 +76,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { fitOutByLandlord: u.fitOutByLandlord, mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm, parkingSpots: u.parkingSpots, + expectedRentPerSqm: u.expectedRentPerSqm, }) setEditingUnit(u.id) } @@ -89,6 +92,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined, mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined, parkingSpots: unitDraft.parkingSpots, + expectedRentPerSqm: unitDraft.expectedRentPerSqm, }, }) setEditingUnit(null) @@ -412,6 +416,21 @@ export function UnitStructurePanel({ p }: { p: Property }) { /> + {u.schattenmarktRelease?.enabled && (() => { + const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm) + return ( + + setUnitDraft(d => ({ ...d, expectedRentPerSqm: parseInt(e.target.value) || undefined }))} + slotProps={{ htmlInput: { min: 0, step: 10 } }} + helperText={suggestion ? `Vorschlag (indexiert): CHF ${suggestion}/m² — leer = heutiger Preis` : 'Leer = heutiger Preis'} + /> + + ) + })()} + {UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && ( Wer baut aus? diff --git a/src/domain/unit.ts b/src/domain/unit.ts index 32eac49..981d8ea 100644 --- a/src/domain/unit.ts +++ b/src/domain/unit.ts @@ -24,6 +24,7 @@ export interface PropertyUnit { fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert) mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert) parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze + expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete leases?: Lease[] // Mietverträge — current, historical, future /** @deprecated Use leases[].tenant.companyName */ currentTenant?: string diff --git a/src/lib/__tests__/rentEstimate.test.ts b/src/lib/__tests__/rentEstimate.test.ts new file mode 100644 index 0000000..6425c99 --- /dev/null +++ b/src/lib/__tests__/rentEstimate.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { estimateMarketRent, suggestFutureRent } from '../rentEstimate' + +describe('estimateMarketRent', () => { + it('flags asking rent clearly above the city median as ABOVE', () => { + // Zürich OFFICE median = 42 + const est = estimateMarketRent('Zürich', 'OFFICE', 60) + expect(est).not.toBeNull() + expect(est!.verdict).toBe('ABOVE') + expect(est!.deltaPct).toBeGreaterThan(5) + expect(est!.fairRentPerSqm).toBe(42) + }) + + it('flags asking rent clearly below median as BELOW', () => { + const est = estimateMarketRent('Zürich', 'OFFICE', 30) + 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) + expect(est!.verdict).toBe('AT') + }) + + it('returns null for unknown city', () => { + expect(estimateMarketRent('Atlantis', 'OFFICE', 40)).toBeNull() + }) + + it('suggestFutureRent indexes by the city rent trend', () => { + // Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded) + expect(suggestFutureRent('Zürich', 100)).toBe(104) + }) +}) diff --git a/src/lib/rentEstimate.ts b/src/lib/rentEstimate.ts new file mode 100644 index 0000000..b953a12 --- /dev/null +++ b/src/lib/rentEstimate.ts @@ -0,0 +1,42 @@ +import { getMarketRent, getCityIntelligence } from './locationIntelligence' + +export type RentVerdict = 'BELOW' | 'AT' | 'ABOVE' + +export interface RentEstimate { + fairRentPerSqm: number // Median-Marktmiete als faire Benchmark + askingRentPerSqm: number + verdict: RentVerdict // Angebot vs. Markt + deltaPct: number // +über / −unter Markt (gerundet) + rationale: string + confidence: 'LOW' | 'MEDIUM' | 'HIGH' +} + +/** + * Markt-Einschätzung der Angebotsmiete: Vergleich gegen die Median-Marktmiete + * (aus Standort-Intelligence) plus Leerstand/Miettrend als Kontext. Deterministisch. + */ +export function estimateMarketRent(city: string, assetType: string, askingRentPerSqm: number): RentEstimate | null { + const market = getMarketRent(city, assetType) + const intel = getCityIntelligence(city) + if (market == null || !intel || askingRentPerSqm <= 0) return null + + const fair = market + const deltaPct = Math.round(((askingRentPerSqm - fair) / fair) * 100) + const verdict: RentVerdict = deltaPct > 5 ? 'ABOVE' : deltaPct < -5 ? 'BELOW' : 'AT' + const trendNote = `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)` + const verdictText = + verdict === 'ABOVE' ? `Angebot ${deltaPct}% über Marktmedian.` + : verdict === 'BELOW' ? `Angebot ${Math.abs(deltaPct)}% unter Marktmedian.` + : 'Angebot marktkonform.' + const rationale = `Median ${city}: CHF ${fair}/m² · Leerstand ${intel.vacancyRatePct}% · ${trendNote}. ${verdictText}` + const confidence = intel.demandStrength === 'LOW' ? 'LOW' : intel.avgDaysOnMarket > 70 ? 'MEDIUM' : 'HIGH' + + return { fairRentPerSqm: fair, askingRentPerSqm, verdict, deltaPct, rationale, confidence } +} + +/** Indexierter Vorschlag für den künftigen Preis (Pre-Market): Heutepreis × (1 + Miettrend). */ +export function suggestFutureRent(city: string, currentRentPerSqm: number): number | null { + const intel = getCityIntelligence(city) + if (!intel || currentRentPerSqm <= 0) return null + return Math.round(currentRentPerSqm * (1 + intel.rentTrend12m / 100)) +} diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index b1b305c..b4c2024 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore' import { AddToPipelineDialog } from '../../components/shortlist' import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence' -import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel } from '../../components/match-detail' +import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail' import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' import { useMatchDetailData } from '../../hooks/useMatchDetailData' import { useMatchDetail } from '../../hooks/useMatches' @@ -39,6 +39,7 @@ export default function MatchDetail() { const { openInquiryDialog } = useInquiryStore() const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '') + const detailUnit = property?.units?.find(u => u.id === match?.unitId) if (isLoading) { return ( @@ -238,6 +239,16 @@ export default function MatchDetail() { /> )} + {/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */} + {property?.location?.city && property.rentPricePerSqm > 0 && ( + + )} + {/* ── Full analysis toggle + expanded panels ── */}