feat(demand): market rent estimate (asking vs. fair) + pre-market expected price

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-20 23:53:00 +02:00
parent fd6ed105bb
commit fca113e7ab
8 changed files with 181 additions and 2 deletions
@@ -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<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
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 (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{chf(est.fairRentPerSqm)}
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
</Box>
</Typography>
</Box>
</Box>
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}></Box> {chf(futureRentPerSqm)}
</Typography>
</Box>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{est.rationale}
</Typography>
</Paper>
)
}
+1
View File
@@ -14,4 +14,5 @@ export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel
export { NextActionsPanel } from './NextActionsPanel' export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel' export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel' export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { MarketPricePanel } from './MarketPricePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -8,6 +8,7 @@ import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/
import { useUpdateUnit } from '../../hooks/useProperties' import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants' import { FIT_OUT_LABELS } from '../../lib/constants'
import { suggestFutureRent } from '../../lib/rentEstimate'
import { floorLabel } from './PropertyDetailHelpers' import { floorLabel } from './PropertyDetailHelpers'
const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC']) const UNIT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
@@ -19,6 +20,7 @@ interface UnitDraft {
fitOutByLandlord?: boolean fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number mieterausbaubeitragPerSqm?: number
parkingSpots?: number parkingSpots?: number
expectedRentPerSqm?: number
} }
function MatchPill({ m }: { m: UnitNeedMatch }) { function MatchPill({ m }: { m: UnitNeedMatch }) {
@@ -74,6 +76,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
fitOutByLandlord: u.fitOutByLandlord, fitOutByLandlord: u.fitOutByLandlord,
mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm, mieterausbaubeitragPerSqm: u.mieterausbaubeitragPerSqm,
parkingSpots: u.parkingSpots, parkingSpots: u.parkingSpots,
expectedRentPerSqm: u.expectedRentPerSqm,
}) })
setEditingUnit(u.id) setEditingUnit(u.id)
} }
@@ -89,6 +92,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined, fitOutByLandlord: needsBuild ? unitDraft.fitOutByLandlord : undefined,
mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined, mieterausbaubeitragPerSqm: needsBuild && unitDraft.fitOutByLandlord === false ? unitDraft.mieterausbaubeitragPerSqm : undefined,
parkingSpots: unitDraft.parkingSpots, parkingSpots: unitDraft.parkingSpots,
expectedRentPerSqm: unitDraft.expectedRentPerSqm,
}, },
}) })
setEditingUnit(null) setEditingUnit(null)
@@ -412,6 +416,21 @@ export function UnitStructurePanel({ p }: { p: Property }) {
/> />
</Box> </Box>
{u.schattenmarktRelease?.enabled && (() => {
const suggestion = suggestFutureRent(p.location?.city ?? '', unitDraft.rentPricePerSqm ?? u.rentPricePerSqm ?? p.rentPricePerSqm)
return (
<Box sx={{ mt: 1.25 }}>
<TextField
label="Erwarteter Preis Pre-Market (CHF/m²)" type="number" size="small" fullWidth
value={unitDraft.expectedRentPerSqm ?? ''}
onChange={e => 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'}
/>
</Box>
)
})()}
{UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && ( {UNIT_NEEDS_BUILD.has((unitDraft.fitOut || p.hardFacts?.fitOut) ?? '') && (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}> <Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography> <Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
+1
View File
@@ -24,6 +24,7 @@ export interface PropertyUnit {
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert) fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert) mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze 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 leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */ /** @deprecated Use leases[].tenant.companyName */
currentTenant?: string currentTenant?: string
+33
View File
@@ -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)
})
})
+42
View File
@@ -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))
}
+12 -1
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence' 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 type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData' import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
@@ -39,6 +39,7 @@ export default function MatchDetail() {
const { openInquiryDialog } = useInquiryStore() const { openInquiryDialog } = useInquiryStore()
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '') const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
if (isLoading) { if (isLoading) {
return ( 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 && (
<MarketPricePanel
city={property.location.city}
assetType={property.assetType}
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
/>
)}
{/* ── Full analysis toggle + expanded panels ── */} {/* ── Full analysis toggle + expanded panels ── */}
<MatchDetailScoreBreakdown <MatchDetailScoreBreakdown
match={match} match={match}
+3 -1
View File
@@ -106,7 +106,9 @@ export function generateMatchesForNeed(need: Need): void {
for (const unit of prop.units!) { for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue if (!unit.schattenmarktRelease?.enabled) continue
const facts = resolveUnitFacts(prop, unit) const facts = resolveUnitFacts(prop, unit)
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY, { // Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete
const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, {
fitOut: facts.fitOut, fitOut: facts.fitOut,
mieterausbaubeitragPerSqm: facts.mabPerSqm, mieterausbaubeitragPerSqm: facts.mabPerSqm,
fitOutByLandlord: facts.fitOutByLandlord, fitOutByLandlord: facts.fitOutByLandlord,