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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user