# Rent Tracker – Berechnungen & API

Stand: 2026-05-17

## Ziel

Der Rent Tracker sammelt Mietanzeigen aus mehreren Portalen und berechnet daraus Marktwerte für Hannover. Die Werte sollen für schnelle Mietmarktübersichten und spätere Kaufobjekt-Vergleiche nutzbar sein.

## Gewichtung aktiver und inaktiver Anzeigen

Inaktive Anzeigen fließen in Markt- und Gebietsdurchschnitte weiterhin ein, aber schwächer als aktive Anzeigen.

| Status | Gewicht |
|---|---:|
| aktiv | `1.00` |
| inaktiv | `0.35` |

Formel für gewichtete Durchschnitte:

```text
gewichteter Durchschnitt = sum(wert_i * gewicht_i) / sum(gewicht_i)
```

Beispiel:

```text
aktive Anzeige:   14,00 €/m² * 1,00
inaktive Anzeige: 12,00 €/m² * 0,35
= (14,00 + 4,20) / (1,35) = 13,48 €/m²
```

Warum: Inaktive Anzeigen sind weiter nützlich, weil sie kürzlich erzielbare/angebotene Mieten zeigen. Sie sollen den Marktwert aber weniger stark prägen, da sie nicht mehr aktuell verfügbar sind.

## Preisbasis

Pro Anzeige wird ein vergleichbarer Mietwert berechnet:

1. bevorzugt `rent_cold_eur`, wenn vorhanden
2. sonst `rent_warm_eur`
3. daraus `rent_comparable_per_sqm = rent_comparable_eur / area_sqm`

Die API liefert die Basis über `rent.comparable_basis` bzw. intern `rent_comparable_basis`.

## Gebietsdurchschnitte

Die Endpunkte für PLZ/Stadtteil und GeoJSON verwenden gewichtete Durchschnitte:

- `avg_price_per_sqm` → gewichtet, aktiv `1.0`, inaktiv `0.35`
- `avg_rent_eur` → gewichtet
- `avg_area_sqm` → gewichtet

Zur Transparenz werden zusätzlich ungewichtete Felder ausgegeben:

- `avg_price_per_sqm_unweighted`
- `avg_rent_eur_unweighted`
- `avg_area_sqm_unweighted`
- `active_count`
- `inactive_count`
- `inactive_weight`

Median/Perzentile bleiben aktuell ungewichtet, damit sie robuste Verteilungswerte bleiben.

## Kartenlogik

Auf der Karte werden Wohnungsmarker anhand der Anzeigenposition dargestellt. Wenn keine genaue Adresse vorhanden ist, wird der Marker deterministisch innerhalb des passenden PLZ-Polygons platziert. Das vermeidet zufälliges Springen zwischen Läufen.

Gebietsflächen verwenden `avg_price_per_sqm`, also den gewichteten Durchschnitt inklusive inaktiver Anzeigen.

## Quellen / Portale

Aktuell konfigurierte Quellen:

- Kleinanzeigen
- immowelt
- ImmoScout24
- meinestadt
- immobilo
- WG-Gesucht
- KIP / Kommunales Immobilienportal
- immobilien.de
- Immoportal

## API Übersicht

Base URL lokal:

```text
http://127.0.0.1:8765
```

Öffentlich, sofern über Reverse Proxy erreichbar:

```text
https://mieten.openclaw.hartjetec.com
```

### `GET /api/listings`

UI-naher Endpunkt für Listen/Marker.

Query:

| Parameter | Werte | Beschreibung |
|---|---|---|
| `active` | `1`, `0`, `all` | aktive, inaktive oder alle Anzeigen |

Beispiel:

```text
/api/listings?active=1
```

### `GET /api/plz-stats`

PLZ-Statistiken mit gewichteten Marktwerten.

Query:

| Parameter | Werte | Beschreibung |
|---|---|---|
| `active` | `1`, `0`, `all` | Datengrundlage; für Marktwerte empfohlen: `all` |

Beispiel:

```text
/api/plz-stats?active=all
```

### `GET /api/stadtteil-stats`

Stadtteil-Statistiken mit gewichteten Marktwerten.

Beispiel:

```text
/api/stadtteil-stats?active=all
```

### `GET /api/plz-geojson`

GeoJSON für PLZ-Flächen. Properties enthalten die gewichteten Statistikfelder.

Beispiel:

```text
/api/plz-geojson?active=all
```

### `GET /api/stadtteil-geojson`

GeoJSON für Stadtteil-Flächen. Properties enthalten die gewichteten Statistikfelder.

Beispiel:

```text
/api/stadtteil-geojson?active=all
```

### `GET /api/summary`

Globale Übersicht. Nutzt alle Anzeigen und gewichtete Marktwerte.

Wichtige Felder:

- `active_count`
- `inactive_count`
- `total_count`
- `avg_price_per_sqm`
- `avg_price_per_sqm_unweighted`
- `median_price_per_sqm`
- `inactive_weight`

### `GET /api/v1/listings`

Normierter Listing-Endpunkt für externe Nutzung und spätere Kaufvergleichslogik.

Query:

| Parameter | Beispiel | Beschreibung |
|---|---|---|
| `active` | `1`, `all` | Statusfilter |
| `source` | `kip` | exakt ein Portal |
| `sources` | `kip,immoportal` | mehrere Portale |
| `plz` | `30173` | PLZ-Filter |
| `stadtteil` | `Südstadt` | Stadtteil-Filter |
| `q` | `balkon` | Volltextsuche |
| `min_area_sqm` | `50` | Mindestfläche |
| `max_area_sqm` | `90` | Maximalfläche |
| `min_rooms` | `2` | Mindestzimmerzahl |
| `max_rooms` | `4` | Maximalzimmerzahl |
| `min_score` | `7` | Mindest-Vergleichsscore |
| `max_rent_eur` | `1200` | maximale Miete |
| `max_price_per_sqm` | `18` | maximale €/m² |
| `features` | `balcony,parking` | Feature-Filter |
| `include_text` | `1` | vollen Anzeigentext einbetten |
| `limit` | `100` | max. Ergebnisse, intern begrenzt |

Beispiel:

```text
/api/v1/listings?sources=kip,immoportal&min_area_sqm=50&max_rent_eur=1200&limit=20
```

### `GET /api/v1/listings/<id>/text`

Liefert den vollen gespeicherten Anzeigentext für eine Anzeige.

Beispiel:

```text
/api/v1/listings/240/text
```

### `GET /api/v1/rent-market`

Vergleichsmiete / Marktwert für eine Zielwohnung. Standardmäßig werden aktive und inaktive Anzeigen berücksichtigt; inaktive mit Gewicht `0.35`.

Query:

| Parameter | Beispiel | Beschreibung |
|---|---|---|
| `area_sqm` | `65` | Fläche des Zielobjekts |
| `rooms` | `2` | Zimmerzahl |
| `plz` | `30173` | PLZ |
| `stadtteil` | `Südstadt` | Stadtteil |
| `active` | `1` oder `all` | optionaler Statusfilter; ohne Parameter = alle gewichtet |
| `limit` | `30` | Anzahl Vergleichsanzeigen |

Beispiel:

```text
/api/v1/rent-market?area_sqm=65&rooms=2&plz=30173
```

Wichtige Felder:

- `market_rent.avg_per_sqm` → gewichtet
- `market_rent.avg_per_sqm_unweighted`
- `market_rent.median_per_sqm`
- `market_rent.estimated_monthly_rent_eur`
- `market_rent.inactive_weight`
- `comparables[].weight`

### `POST /api/v1/purchase-comparison`

Vergleicht ein Kaufobjekt mit potenzieller Vergleichsmiete.

Body-Beispiel:

```json
{
  "purchase_price_eur": 250000,
  "area_sqm": 55,
  "rooms": 2,
  "plz": "30173",
  "limit": 30
}
```

Wichtige Ergebnisfelder:

- `rent_market.estimated_monthly_rent_eur`
- `purchase_metrics.purchase_price_per_sqm`
- `purchase_metrics.gross_yield_pct`
- `purchase_metrics.price_to_annual_rent_multiple`
- `comparables`

Optional kann `expected_monthly_rent_eur` übergeben werden, um die automatisch geschätzte Miete zu überschreiben.
