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
+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))
}