Immobilienmarkt-Signal v1 — öffentlicher Vertrag
Werkzeug T04 (#575, ADR-0069,
ADR-0070). Seite:
/werkzeuge/immobilienmarkt. Headless: GET /api/tools/immobilienmarkt.
Zweck und Abgrenzung
Das Werkzeug beantwortet ausschließlich: Wie hat sich das offizielle Preisniveau für Wohnimmobilien in Österreich entwickelt?
Es liefert keine Objekt-, Adress-, Bezirks- oder Bundesland-Werte, kein €/m², keine Bewertung, keine Kauf- oder Verkaufsempfehlung und keine Prognose. Für einen konkreten Standort ist der Standort-Check das zuständige Werkzeug.
Quellen und Granularität
| Karte | Quelle | Indikator | Region | Einheit |
|---|---|---|---|---|
| Häuserpreisindex Österreich | Statistik Austria (statat) | statat:ogd_hpi2015_hpi_15_1:f-hpi1 | Österreich | Index (2015 = 100) |
| EU-Vergleich | Eurostat (eurostat) | eurostat:hpi:total:i15_q | Österreich, EU-27 | Index (2015 = 100) |
| Fundamentalpreisindikator | OeNB (oenb) | oenb:fpi:overall | Österreich, Wien | Abweichung in Prozent |
Bundesländer gibt es in keiner dieser Quellen; die Begründung steht in ADR-0070. Der Fundamentalpreisindikator ist eine Modellschätzung, keine Preismessung.
Datenbindung
Gelesen wird ausschließlich die Sicht price_facts_current: Zeilen des
aktiven, für public_display qualifizierten Release je Quelle. Zusätzlich
prüft das Werkzeug je Karte, bevor es Werte zeigt:
- Die Lane der Quelle in der Coverage-/Rechtematrix ist eindeutig, trägt die
Fähigkeit
index_lookup, den Kanalpublicund die Werkzeug-IDmarket-signal. - Alle neun Bundesland-Zellen der Lane sind
availableund enthalten den nationalen Coverage-Keycountry:AT. - Die öffentliche Provenienz nennt denselben aktiven Release und liefert Namensnennung, Lizenz-URL und kanonische Quell-URL.
- Jede gelesene Zeile trägt die Release-ID dieses aktiven Release.
Fehlt eine Bedingung, bleibt die Karte ohne Werte und nennt den Grund.
Kartenzustände
| Zustand | Bedeutung |
|---|---|
available | Quelle freigegeben, Werte aus dem aktiven Release |
gated | Quelle für dieses Werkzeug nicht freigegeben |
stale | Freigegebener Quellenstand ist veraltet |
failed | Datenstand vorübergehend nicht abrufbar oder ohne Werte |
Ein gated- oder failed-Zustand ist kein Befund über den Markt.
Antwortvertrag
GET /api/tools/immobilienmarkt
Antwort 200 mit { checkedAt, matrixVersion, cards[], limitations[] }. Jede
Karte trägt id, title, question, state, summary, unitLabel,
limitation und im Zustand available zusätzlich series (bis zu 20
Perioden), optional comparison, sourceLabel, attribution,
licenseLabel, licenseUrl, sourceUrl, retrievedAt, sourceVintage,
releaseId und freshness.
Header: Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600,
X-Robots-Tag: noindex, nofollow, noarchive, ETag über Matrixversion und
Karten (ohne Prüfzeitpunkt). Ein If-None-Match mit gültigem ETag wird mit
304 beantwortet, ebenfalls mit CORS- und nosniff-Headern. Fehlerfall:
503 market_signal_unavailable mit Cache-Control: no-store.
Diese Direktiven setzt die Route. Hinter Vercel sieht ein Client sie nicht
vollständig: das Edge verbraucht s-maxage und stale-while-revalidate selbst
und gibt Cache-Control: public, max-age=0 weiter. Der Vertrag ist deshalb
nicht am Antwort-Header messbar, sondern am Edge-Verhalten — x-vercel-cache
wechselt nach dem ersten Abruf auf HIT und age zählt hoch. Am Ursprung
prüft tests/unit/app/api/tools/immobilienmarkt.test.ts die vollständige
Direktivenliste.
Prüffälle
Mindestens 20 reproduzierbare, nummerierte Fälle, analog zum Standort-Check-Vertrag. Jeder Fall nennt das erwartete Ergebnis und den automatisierten Test, der ihn belegt.
| Fall | Prüfsituation | Erwartetes Ergebnis | Test |
|---|---|---|---|
| MS-01 | Freigegebene Quelle mit Reihe, Veränderung und Attribution | state=available, changeYoyPercent gesetzt | tests/unit/lib/tools/market-signal.test.ts |
| MS-02 | Quelle ohne Provenienz-Eintrag | state=gated, Grund nennt fehlende Angaben | tests/unit/lib/tools/market-signal.test.ts |
| MS-03 | Lane für die Quelle fehlt oder ist nicht eindeutig | state=gated, „Der Quellenvertrag ist nicht eindeutig." | tests/unit/lib/tools/market-signal.test.ts |
| MS-04 | Lane trägt nicht die Tool-ID market-signal (falsche Tool-ID) | state=gated | tests/unit/lib/tools/market-signal.test.ts |
| MS-05 | Eine einzelne Bundesland-Zelle ist nicht available (nicht alle neun Zellen) | state=gated | tests/unit/lib/tools/market-signal.test.ts |
| MS-06 | Mindestens eine Zelle mit freshness.state=stale | state=stale | tests/unit/lib/tools/market-signal.test.ts |
| MS-07 | Coverage-Key country:AT fehlt in einer Zelle | state=gated | tests/unit/lib/tools/market-signal.test.ts |
| MS-08 | Gelesene Zeile trägt eine fremde Release-ID | state=gated, „stimmt nicht mit dem aktiven Quellenstand überein" | tests/unit/lib/tools/market-signal.test.ts |
| MS-09 | Leseausfall beim Laden der Fakten (Exception) | state=failed | tests/unit/lib/tools/market-signal.test.ts |
| MS-10 | Leere Reihe nach dem Lesen | state=failed, „keine Werte" | tests/unit/lib/tools/market-signal.test.ts |
| MS-11 | Fehlende Attribution oder Lizenz-URL in der Provenienz | state=gated | tests/unit/lib/tools/market-signal.test.ts |
| MS-12 | Provenienz-Release-ID weicht von der Matrixzelle ab | state=gated | tests/unit/lib/tools/market-signal.test.ts |
| MS-13 | Rohreihe mischt Jahres- und Quartalswerte (gemischte Frequenzen) | Reihe und Veränderung enthalten nur eine Frequenz | tests/unit/lib/tools/market-signal.test.ts |
| MS-14 | EU-/Wien-Vergleichsreihe ist im aktiven Release vorhanden | comparison gesetzt | tests/unit/lib/tools/market-signal.test.ts |
| MS-15 | EU-/Wien-Vergleichsreihe fehlt im aktiven Release (fehlender EU-Vergleich) | comparison=null, Hauptkarte bleibt available | tests/unit/lib/tools/market-signal.test.ts |
| MS-16 | Wert trägt das Flag p (vorläufig) oder e (geschätzt) | point.status wird unverändert durchgereicht | tests/unit/lib/tools/market-signal.test.ts |
| MS-17 | Quellenstand (sourceVintage) weicht von der jüngsten Periode der Reihe ab | vintageMismatch=true, Karte bleibt available | tests/unit/lib/tools/market-signal.test.ts |
| MS-18 | GET ohne If-None-Match | 200 mit ETag, vollständiger Cache-Control-Direktivenliste, noindex, CORS * | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-19 | If-None-Match passt (Einzelwert, kommagetrennte Liste, *) | 304, ETag erhalten | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-20 | ETag über zwei Abrufe mit unterschiedlichem checkedAt, aber gleichen Karten | identisches ETag; ändert sich eine Karte, ändert sich das ETag | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-21 | buildMarketSignal() wirft (Aufbaufehler) | 503 market_signal_unavailable, Cache-Control: no-store | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-22 | Antwort auf 200, 304 und 503 auf Set-Cookie geprüft | kein Set-Cookie in keinem der drei Pfade | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-23 | OPTIONS-Preflight | 204 | tests/unit/app/api/tools/immobilienmarkt.test.ts |
| MS-24 | Alle vier Kartenzustände (available/gated/stale/failed) gerendert | je ein eigenes, unterscheidbares Label | tests/unit/components/market-signal-render.test.ts |
| MS-25 | Statusflag p/e in Wert und Tabelle sichtbar | „(vorläufig)" bzw. „(geschätzt)" getrennt ausgewiesen | tests/unit/components/market-signal-render.test.ts |
| MS-26 | Eurostat-Karte mit attribution und retrievedAt | Namensnennung ergänzt „abgerufen am …" (Abrufdatum) | tests/unit/components/market-signal-render.test.ts |
| MS-27 | Totalausfall (alle drei Karten failed) vs. keine Freigabe (alle drei gated) | zwei unterscheidbare Texte; „vorübergehend nicht abrufbar" impliziert keine Freigabeaussage | tests/unit/components/market-signal-render.test.ts |
| MS-28 | Gemischte Kartenzustände (z. B. eine available, zwei nicht) | Zusammenfassung nennt die korrekte Anzahl freigegebener Quellen | tests/unit/components/market-signal-render.test.ts |
| MS-29 | „Geprüft am"-Zeitstempel auf der Seite | Datum/Uhrzeit im Format Europe/Vienna | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-30 | Ergebnis mit mindestens einer freigegebenen Karte (qualifizierter Abschluss) | MET-01: funnel_start gefolgt von funnel_complete | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-31 | Ergebnis ohne freigegebene Karte (keine Quelle verfügbar) | MET-01: funnel_start gefolgt von funnel_honest_terminal | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-32 | buildMarketSignal() wirft beim Seitenaufbau | Fallback-Karten failed; MET-01 zählt funnel_honest_terminal | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-33 | market-signal in INSTRUMENTED_TOOL_IDS, Provider fest auf "none" gebunden | provider≠"none" wird abgelehnt (invalid_provider); pv-yield mit provider="none" ebenso | tests/unit/lib/metrics/tool-metrics.test.ts |
| MS-34 | Providerloser Lauf mit falscher Region, falschem Providerstatus oder falschem Kostenstatus | jeweils abgelehnt (invalid_region_code/invalid_provider_status/invalid_cost_status) | tests/unit/lib/metrics/tool-metrics.test.ts |
| MS-35 | Providerloser Lauf mit Outcome rate_limited/provider_* | abgelehnt (inconsistent_server_metric) | tests/unit/lib/metrics/tool-metrics.test.ts |
| MS-36 | market-signal an POST /api/metrics/tool-event (öffentlicher Client-Endpoint) | 400, recordFunnelMetric wird nicht aufgerufen | tests/unit/app/api/metrics/tool-event.test.ts |
| MS-37 | /api/metrics/tool-event und /api/tools/immobilienmarkt im Auth.js-Matcher | beide Pfade ausdrücklich ausgenommen (kein CSRF-Cookie) | tests/unit/app/seo-public-routes.test.ts |
| MS-38 | Gewöhnlicher Browseraufruf (Chrome-UA, Accept: text/html…) | wird gezählt (countable: true) | tests/unit/lib/metrics/request-classification.test.ts |
| MS-39 | Next.js-<Link>-Prefetch (next-router-prefetch-Header) | wird nicht gezählt (reason: "prefetch") | tests/unit/lib/metrics/request-classification.test.ts |
| MS-40 | Anfrage mit purpose: prefetch oder sec-purpose: prefetch;… | wird nicht gezählt (reason: "prefetch") | tests/unit/lib/metrics/request-classification.test.ts |
| MS-41 | Anfrage ohne text/html im Accept-Header und ohne rsc: 1 (z. B. */*, application/json) | wird nicht gezählt (reason: "not_html_accept") | tests/unit/lib/metrics/request-classification.test.ts |
| MS-42 | Anfrage ohne User-Agent | wird nicht gezählt (reason: "missing_user_agent") | tests/unit/lib/metrics/request-classification.test.ts |
| MS-43 | Bekannter Bot-/Nicht-Browser-User-Agent (Googlebot, curl, AhrefsBot, python-requests, node-fetch, axios, Go-http-client, Java, HeadlessChrome, Lighthouse, facebookexternalhit, Embedly u. a.) | wird nicht gezählt (reason: "automated_user_agent") | tests/unit/lib/metrics/request-classification.test.ts |
| MS-44 | Unauffälliger mobiler Browser (Safari/iOS-UA) | wird gezählt — kein Fehlalarm | tests/unit/lib/metrics/request-classification.test.ts |
| MS-45 | Seitenaufruf mit Bot-/Prefetch-Headern auf der echten Seite (nicht nur am Klassifikator isoliert) | recordFunnelMetric wird nicht aufgerufen | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-46 | User-Agent-Prüfung wird nicht geloggt oder gespeichert | kein console.error-Aufruf enthält den geprüften User-Agent-Wert | tests/unit/app/werkzeuge/immobilienmarkt-page.test.ts |
| MS-47 | Navigation innerhalb der Website: rsc: 1, Accept: */*, Browser-UA | wird gezählt (countable: true); mit Prefetch-Header oder Bot-UA nicht | tests/unit/lib/metrics/request-classification.test.ts |
Namensnennung
Jede freigegebene Karte gibt Namensnennung und Lizenz sichtbar aus. Für
Eurostat verlangt der Rechte-Review zusätzlich das Abrufdatum; die Oberfläche
ergänzt es aus retrievedAt des aktiven Release.
Datenschutz
Keine Eingabe, keine Adresse, keine Koordinate, kein Cookie, keine
Persistenz von Adresse/Koordinate/Ergebnis. Die Antwort ist für alle
Abrufenden identisch und wird deshalb am CDN fünf Minuten (s-maxage=300)
cachebar ausgeliefert, mit einem zehnminütigen Stale-Fenster
(stale-while-revalidate=600); ein Nutzerzähler oder Rate-Limit ist dafür
nicht nötig. Seit Slice 3 (#575) zählt die Seite (nicht die API)
kennungsfreie MET-01-Tagesaggregate, siehe „Messung (MET-01)" unten. Details
unter /datenschutz#immobilienmarkt.
Messung (MET-01)
market-signal ist seit Slice 3 (#575) in INSTRUMENTED_TOOL_IDS
(lib/metrics/tool-metrics.ts), mit Provider fest auf "none" gebunden
(TOOL_PROVIDERS): das Werkzeug ruft keinen externen Dienst auf und hat
deshalb keine Providerlauf-, Kosten- oder Regionsdimension.
Was gezählt wird. Nur die Seite /werkzeuge/immobilienmarkt zählt, nicht
GET /api/tools/immobilienmarkt: Die API ist CDN-gecacht
(s-maxage=300), eine Zählung dort würde nur Origin-Treffer (Cache-Misses)
erfassen und damit die tatsächliche Nutzung systematisch unterzählen sowie
den Aktivierungsfunnel künstlich verdoppeln (Seite und API könnten denselben
Aufruf zweimal zählen). Die Seite ist force-dynamic: jeder Seitenaufruf
erreicht den Ursprung, ein Zähler dort ist vollständig.
Wie gezählt wird. Die Seite hat kein Client-JavaScript, kann also keinen
Funnel selbst über POST /api/metrics/tool-event melden. Stattdessen ruft
sie serverseitig, nicht blockierend über after(), direkt
recordFunnelMetric auf — einmal funnel_start, einmal funnel_complete
oder funnel_honest_terminal, aber nur für Anfragen, die
classifyPageViewRequest() (lib/metrics/request-classification.ts) als
echten Browser-Seitenaufruf einstuft (siehe unten). Ein qualifizierter
Abschluss (funnel_complete) ist damit kein Eingabe-Abschluss — dieses
Werkzeug hat keine Eingabe —, sondern ein Browser-Aufruf, bei dem
mindestens eine Karte freigegeben war (state: "available"); sonst ist
der Endzustand funnel_honest_terminal — ein Browser-Aufruf entweder ohne
freigegebene Quelle oder mit internem Fehler in buildMarketSignal(). Das
ist kein Bug: Ein gated/stale/failed-Zustand ist laut Vertrag kein
negativer Marktbefund, aber auch kein qualifizierter Abschluss im Sinn von
MET-01. Diese Definition weicht von der Tag-90-KPI „Tool-Nutzen“ ab
(dort: „valides Ergebnis … nach vollständiger Eingabe“); der Zähler wird
deshalb getrennt ausgewiesen und fließt nicht in die Kennzahl „150
qualifizierte Tool-Abschlüsse“ ein (docs/ROADMAP-90-TAGE-PLATTFORM.md,
Abschnitt „KPI-Scorecard“).
Eigene Prüfläufe (seit ADR-0079) zählen getrennt: Trägt ein gezählter
Seitenaufruf eine gültige interne Markierung (Cookie ln_qa mit
signiertem Prüfmodus-Flag oder Header X-LandNutzen-Internal-Run mit Flag
bzw. Skript-Token), landen Start und Endzustand nur in
tool_metrics_internal_daily (Migration 0066), nicht in den Werten unten.
Die Bot- und Prefetch-Regeln gelten unverändert auch mit Markierung.
Nicht gezählt werden (classifyPageViewRequest(), geprüft werden nur
Header, nie Body/IP; der User-Agent-Wert wird ausschließlich flüchtig per
Regex getestet, nie gespeichert oder geloggt):
- Next.js-
<Link>-Prefetch (next-router-prefetch-Header) sowie generische Prefetch-Signale (purpose/sec-purposemitprefetch); - Anfragen ohne
text/htmlimAccept-Header, außer Navigationen innerhalb der Website (Next.js-Link, Headerrsc: 1), die als Aufruf gezählt werden; - Anfragen ohne
User-Agentoder mit einem bekannten Bot-/Nicht-Browser- Muster (bot|crawl|spider|slurp|preview|headless|lighthouse|curl|wget| python|node-fetch|axios|go-http|java|facebookexternalhit|embedly, sieheAUTOMATED_USER_AGENT_PATTERN).
Bekannte Lücke: Next.js-Server-Components haben über headers() keinen
Zugriff auf die HTTP-Methode; ein reiner HEAD-Aufruf (typisch für
Monitoring) kann deshalb nicht direkt anhand der Methode ausgeschlossen
werden. proxy.ts nimmt werkzeuge(?:/|$) bewusst aus der Auth.js-Hülle
aus (ein bloßer Durchlauf würde ein CSRF-Set-Cookie auslösen), deshalb
wird hier nicht auf Middleware ausgewichen. In der Praxis senden reale
HEAD-Aufrufer (Monitoring, curl -I) fast nie gleichzeitig einen vollen
Browser-Accept-Header und eine unauffällige Browser-User-Agent-Kennung,
wodurch die obigen Filter die meisten Fälle trotzdem abdecken — ein
garantierter Ausschluss ist das nicht.
Innerhalb dieser Grenzen ist die Zählung dennoch belastbarer als ein
clientseitiger Funnel: Weil sie serverseitig erfolgt, ist sie kein
„untrusted" Telemetriewert, der über den öffentlichen Metrik-Endpoint
verzerrt werden könnte. /api/metrics/tool-event ist bewusst so gehärtet
(CLIENT_FUNNEL_TOOL_IDS in lib/metrics/tool-metrics.ts), dass es
market-signal nicht entgegennimmt — sonst könnte ein beliebiger
externer Aufruf über den öffentlichen, kennungsfreien Endpunkt zusätzliche
Ereignisse einschleusen und genau diesen Vorteil wieder zunichtemachen.
Warum keine server_run-Zeile. tool_metrics_daily bindet den
Metrik-Typ server_run per Datenbank-Check-Constraint
(tool_metrics_daily_server_consistency_chk,
lib/db/migrations/0038_tool_metrics_daily.sql) fest an provider = 'pvgis'; ein server_run mit provider = 'none' verletzt diese
Constraint (reproduzierbar belegt in
docs/evidence/t04-release-gates-2026-09-22.md).
Migration 0065 (04.10.2026) hat provider = 'none' bei server_run
ausschließlich für tool_id = 'site-check' zugelassen; für market-signal
gibt es sie nicht. Für ein Werkzeug ohne externen Dienst bleibt hier nur der
bereits provider = 'none' zugelassene Funnel-Pfad. Deshalb hat market-signal keine Laufzeit-, Kosten- oder
Bundeslandauswertung und keine eigene 14-Tage-Baseline im Sinn von
pv-yield: Admin-Ansichten weisen die dafür konstant bleibenden
Provider-/Kostenspalten ausdrücklich als „bauartbedingt: kein externer
Dienst, keine Direktkosten" aus.
Baseline. Die unveränderliche MET-01-Baseline von pv-yield
(tool_metric_baselines, scripts/verify-tool-metrics-baseline.ts) bleibt
unverändert; für market-signal wird keine rückwirkende oder synthetische
Baseline angelegt. Messbeginn ab Merge dieses Slices; Werte vor diesem
Zeitpunkt existieren nicht.
Validierung. parseServerRunMetricInput bindet den Provider je
Werkzeug (TOOL_PROVIDERS) und erzwingt für provider: "none":
providerStatus: "not_called", providerDurationMs: null, regionCode: null, costStatus: "known_zero", directCostMicrousd: 0 und
outcome ∈ {"success", "internal_error"}. Das ist getestet, auch wenn
market-signal aus obigem Grund derzeit ausschließlich über den
Funnel-Pfad zählt.