Dokumentation: Seiten auswählen

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

KarteQuelleIndikatorRegionEinheit
Häuserpreisindex ÖsterreichStatistik Austria (statat)statat:ogd_hpi2015_hpi_15_1:f-hpi1ÖsterreichIndex (2015 = 100)
EU-VergleichEurostat (eurostat)eurostat:hpi:total:i15_qÖsterreich, EU-27Index (2015 = 100)
FundamentalpreisindikatorOeNB (oenb)oenb:fpi:overallÖsterreich, WienAbweichung 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:

  1. Die Lane der Quelle in der Coverage-/Rechtematrix ist eindeutig, trägt die Fähigkeit index_lookup, den Kanal public und die Werkzeug-ID market-signal.
  2. Alle neun Bundesland-Zellen der Lane sind available und enthalten den nationalen Coverage-Key country:AT.
  3. Die öffentliche Provenienz nennt denselben aktiven Release und liefert Namensnennung, Lizenz-URL und kanonische Quell-URL.
  4. 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

ZustandBedeutung
availableQuelle freigegeben, Werte aus dem aktiven Release
gatedQuelle für dieses Werkzeug nicht freigegeben
staleFreigegebener Quellenstand ist veraltet
failedDatenstand 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.

FallPrüfsituationErwartetes ErgebnisTest
MS-01Freigegebene Quelle mit Reihe, Veränderung und Attributionstate=available, changeYoyPercent gesetzttests/unit/lib/tools/market-signal.test.ts
MS-02Quelle ohne Provenienz-Eintragstate=gated, Grund nennt fehlende Angabentests/unit/lib/tools/market-signal.test.ts
MS-03Lane für die Quelle fehlt oder ist nicht eindeutigstate=gated, „Der Quellenvertrag ist nicht eindeutig."tests/unit/lib/tools/market-signal.test.ts
MS-04Lane trägt nicht die Tool-ID market-signal (falsche Tool-ID)state=gatedtests/unit/lib/tools/market-signal.test.ts
MS-05Eine einzelne Bundesland-Zelle ist nicht available (nicht alle neun Zellen)state=gatedtests/unit/lib/tools/market-signal.test.ts
MS-06Mindestens eine Zelle mit freshness.state=stalestate=staletests/unit/lib/tools/market-signal.test.ts
MS-07Coverage-Key country:AT fehlt in einer Zellestate=gatedtests/unit/lib/tools/market-signal.test.ts
MS-08Gelesene Zeile trägt eine fremde Release-IDstate=gated, „stimmt nicht mit dem aktiven Quellenstand überein"tests/unit/lib/tools/market-signal.test.ts
MS-09Leseausfall beim Laden der Fakten (Exception)state=failedtests/unit/lib/tools/market-signal.test.ts
MS-10Leere Reihe nach dem Lesenstate=failed, „keine Werte"tests/unit/lib/tools/market-signal.test.ts
MS-11Fehlende Attribution oder Lizenz-URL in der Provenienzstate=gatedtests/unit/lib/tools/market-signal.test.ts
MS-12Provenienz-Release-ID weicht von der Matrixzelle abstate=gatedtests/unit/lib/tools/market-signal.test.ts
MS-13Rohreihe mischt Jahres- und Quartalswerte (gemischte Frequenzen)Reihe und Veränderung enthalten nur eine Frequenztests/unit/lib/tools/market-signal.test.ts
MS-14EU-/Wien-Vergleichsreihe ist im aktiven Release vorhandencomparison gesetzttests/unit/lib/tools/market-signal.test.ts
MS-15EU-/Wien-Vergleichsreihe fehlt im aktiven Release (fehlender EU-Vergleich)comparison=null, Hauptkarte bleibt availabletests/unit/lib/tools/market-signal.test.ts
MS-16Wert trägt das Flag p (vorläufig) oder e (geschätzt)point.status wird unverändert durchgereichttests/unit/lib/tools/market-signal.test.ts
MS-17Quellenstand (sourceVintage) weicht von der jüngsten Periode der Reihe abvintageMismatch=true, Karte bleibt availabletests/unit/lib/tools/market-signal.test.ts
MS-18GET ohne If-None-Match200 mit ETag, vollständiger Cache-Control-Direktivenliste, noindex, CORS *tests/unit/app/api/tools/immobilienmarkt.test.ts
MS-19If-None-Match passt (Einzelwert, kommagetrennte Liste, *)304, ETag erhaltentests/unit/app/api/tools/immobilienmarkt.test.ts
MS-20ETag über zwei Abrufe mit unterschiedlichem checkedAt, aber gleichen Kartenidentisches ETag; ändert sich eine Karte, ändert sich das ETagtests/unit/app/api/tools/immobilienmarkt.test.ts
MS-21buildMarketSignal() wirft (Aufbaufehler)503 market_signal_unavailable, Cache-Control: no-storetests/unit/app/api/tools/immobilienmarkt.test.ts
MS-22Antwort auf 200, 304 und 503 auf Set-Cookie geprüftkein Set-Cookie in keinem der drei Pfadetests/unit/app/api/tools/immobilienmarkt.test.ts
MS-23OPTIONS-Preflight204tests/unit/app/api/tools/immobilienmarkt.test.ts
MS-24Alle vier Kartenzustände (available/gated/stale/failed) gerendertje ein eigenes, unterscheidbares Labeltests/unit/components/market-signal-render.test.ts
MS-25Statusflag p/e in Wert und Tabelle sichtbar„(vorläufig)" bzw. „(geschätzt)" getrennt ausgewiesentests/unit/components/market-signal-render.test.ts
MS-26Eurostat-Karte mit attribution und retrievedAtNamensnennung ergänzt „abgerufen am …" (Abrufdatum)tests/unit/components/market-signal-render.test.ts
MS-27Totalausfall (alle drei Karten failed) vs. keine Freigabe (alle drei gated)zwei unterscheidbare Texte; „vorübergehend nicht abrufbar" impliziert keine Freigabeaussagetests/unit/components/market-signal-render.test.ts
MS-28Gemischte Kartenzustände (z. B. eine available, zwei nicht)Zusammenfassung nennt die korrekte Anzahl freigegebener Quellentests/unit/components/market-signal-render.test.ts
MS-29„Geprüft am"-Zeitstempel auf der SeiteDatum/Uhrzeit im Format Europe/Viennatests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-30Ergebnis mit mindestens einer freigegebenen Karte (qualifizierter Abschluss)MET-01: funnel_start gefolgt von funnel_completetests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-31Ergebnis ohne freigegebene Karte (keine Quelle verfügbar)MET-01: funnel_start gefolgt von funnel_honest_terminaltests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-32buildMarketSignal() wirft beim SeitenaufbauFallback-Karten failed; MET-01 zählt funnel_honest_terminaltests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-33market-signal in INSTRUMENTED_TOOL_IDS, Provider fest auf "none" gebundenprovider≠"none" wird abgelehnt (invalid_provider); pv-yield mit provider="none" ebensotests/unit/lib/metrics/tool-metrics.test.ts
MS-34Providerloser Lauf mit falscher Region, falschem Providerstatus oder falschem Kostenstatusjeweils abgelehnt (invalid_region_code/invalid_provider_status/invalid_cost_status)tests/unit/lib/metrics/tool-metrics.test.ts
MS-35Providerloser Lauf mit Outcome rate_limited/provider_*abgelehnt (inconsistent_server_metric)tests/unit/lib/metrics/tool-metrics.test.ts
MS-36market-signal an POST /api/metrics/tool-event (öffentlicher Client-Endpoint)400, recordFunnelMetric wird nicht aufgerufentests/unit/app/api/metrics/tool-event.test.ts
MS-37/api/metrics/tool-event und /api/tools/immobilienmarkt im Auth.js-Matcherbeide Pfade ausdrücklich ausgenommen (kein CSRF-Cookie)tests/unit/app/seo-public-routes.test.ts
MS-38Gewöhnlicher Browseraufruf (Chrome-UA, Accept: text/html…)wird gezählt (countable: true)tests/unit/lib/metrics/request-classification.test.ts
MS-39Next.js-<Link>-Prefetch (next-router-prefetch-Header)wird nicht gezählt (reason: "prefetch")tests/unit/lib/metrics/request-classification.test.ts
MS-40Anfrage mit purpose: prefetch oder sec-purpose: prefetch;…wird nicht gezählt (reason: "prefetch")tests/unit/lib/metrics/request-classification.test.ts
MS-41Anfrage 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-42Anfrage ohne User-Agentwird nicht gezählt (reason: "missing_user_agent")tests/unit/lib/metrics/request-classification.test.ts
MS-43Bekannter 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-44Unauffälliger mobiler Browser (Safari/iOS-UA)wird gezählt — kein Fehlalarmtests/unit/lib/metrics/request-classification.test.ts
MS-45Seitenaufruf mit Bot-/Prefetch-Headern auf der echten Seite (nicht nur am Klassifikator isoliert)recordFunnelMetric wird nicht aufgerufentests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-46User-Agent-Prüfung wird nicht geloggt oder gespeichertkein console.error-Aufruf enthält den geprüften User-Agent-Werttests/unit/app/werkzeuge/immobilienmarkt-page.test.ts
MS-47Navigation innerhalb der Website: rsc: 1, Accept: */*, Browser-UAwird gezählt (countable: true); mit Prefetch-Header oder Bot-UA nichttests/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-purpose mit prefetch);
  • Anfragen ohne text/html im Accept-Header, außer Navigationen innerhalb der Website (Next.js-Link, Header rsc: 1), die als Aufruf gezählt werden;
  • Anfragen ohne User-Agent oder 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, siehe AUTOMATED_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.