Two surfaces wired up so the user can "get a vibe of the market": **Per-listing** — each PropertyCard now shows a small pill next to the price when the listing's total_price moved >=1% over a 14-day lookback (e.g. "↓ £200 (-4%) in 14d"). Drops render green, rises render red. Computed from `price_history_json` by the daily aggregator and denormalised onto the listing row so the streaming endpoint just passes it through. **Macro** — new always-visible inline strip above the chip strip showing today's median total price, median £/m², and listing count for the current filter's bedroom band, each with a 30-day % delta: "Rent · 1-2 bed · 30d: Median £2,500 ↓ -4% · £/m² £50 ↓ -2% · Listings 4,200 ↑ +5%". Both data sources are populated daily at 04:00 UTC by a new Celery beat task that fires 1h after the 03:00 RENT scrape and feeds two sinks: a per-listing update pass and an upsert to a new `dailylistingaggregate` table keyed on (snapshot_date, listing_type, min_bedrooms, max_bedrooms). ## Backend - `models/listing.py`: Listing parent gains `price_14d_ago` + `price_ change_pct_14d` nullable floats (inherited by RentListing/BuyListing). New `DailyListingAggregate` table model with unique constraint on (date, type, min_bed, max_bed). - Alembic `a8b9c0d1e2f3`: adds the two columns to both listing tables and creates the aggregate table + date index. - `services/market_aggregator.py` (new): `compute_trend_for_listing`, `update_per_listing_trend` (batched, idempotent), `_stats` (median + mean filtered to positive finite values), `compute_aggregate_ snapshot` (dialect-aware MySQL / SQLite upsert), `fetch_trend_ series` (range query for the API). - `tasks/market_tasks.py` (new): `compute_daily_market_aggregates_task` Celery task wrapping both stages. - `tasks/listing_tasks.py:setup_periodic_tasks`: registers the daily task at 04:00 UTC alongside the existing scrape schedules. - `celery_app.py`: includes the new tasks module. - `api/app.py`: new `GET /api/market_trend?listing_type=&min_bedrooms=& max_bedrooms=&days=` endpoint returning the daily series. - `ui_exporter.py`: GeoJSON feature properties now carry `price_14d_ago` and `price_change_pct_14d` so the frontend can render the badge without an extra round-trip. ## Frontend - `types/index.ts`: new `MarketTrendPoint`; `PropertyProperties` gains the two optional trend fields. - `components/PropertyCard.tsx`: derived `trendBadge` (>=1% threshold, null-safe) rendered as a small pill on both card variants. - `hooks/useMarketTrend.ts` (new): fetches the trend series, derives current-vs-oldest deltas per metric (% change rounded to 1dp). - `components/MarketTrendStrip.tsx` (new): compact inline strip with three metric cells. Hidden when the aggregator hasn't produced any rows yet (graceful start during the first week post-launch). - `App.tsx`: renders the strip above the chip strip whenever the active queryParameters are known. ## Tests - pytest: 10 new (trend math edge cases including null history, malformed JSON, only-recent entries, drops, rises, zero current price; _stats empty / nonpositive filtering; upsert idempotency on an in-memory SQLite seed). 34 decision + aggregator tests pass. - vitest: 8 new (useMarketTrend fetch URL, two-point delta, single-point null delta, empty series; PropertyCard trend badge arrow direction + sign for drops/rises, noise threshold, null guard). 229 tests pass total, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
296 lines
9.9 KiB
Python
296 lines
9.9 KiB
Python
from __future__ import annotations
|
|
from dataclasses import asdict, dataclass
|
|
import dataclasses
|
|
from datetime import datetime, timedelta
|
|
import enum
|
|
import json
|
|
from typing import Any, Dict, List
|
|
from pydantic import BaseModel, Field as PydanticField, model_validator
|
|
from rec import routing
|
|
from sqlalchemy import UniqueConstraint
|
|
from sqlmodel import JSON, TEXT, SQLModel, Field
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PriceHistoryItem:
|
|
first_seen: datetime
|
|
last_seen: datetime
|
|
price: float
|
|
|
|
def to_dict(self) -> Dict[str, float | str]:
|
|
return {
|
|
"first_seen": self.first_seen.isoformat(),
|
|
"last_seen": self.last_seen.isoformat(),
|
|
"price": self.price,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Route:
|
|
legs: list[RouteLegStep]
|
|
distance_meters: int
|
|
duration_s: int
|
|
|
|
@property
|
|
def duration(self) -> timedelta:
|
|
return timedelta(seconds=self.duration_s)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RouteLegStep:
|
|
distance_meters: int
|
|
duration_s: int
|
|
travel_mode: routing.TravelMode
|
|
|
|
@property
|
|
def duration(self) -> timedelta:
|
|
return timedelta(seconds=self.duration_s)
|
|
|
|
|
|
class ListingSite(enum.StrEnum):
|
|
RIGHTMOVE = "rightmove"
|
|
# ZOOPLA = "zoopla"
|
|
# ... add more
|
|
|
|
|
|
def _parse_price_history(price_history_json: str) -> list[PriceHistoryItem]:
|
|
"""Parse a JSON string into a list of PriceHistoryItem objects."""
|
|
if not price_history_json:
|
|
return []
|
|
parsed: list = json.loads(str(price_history_json))
|
|
return [
|
|
PriceHistoryItem(
|
|
first_seen=datetime.fromisoformat(item["first_seen"]),
|
|
last_seen=datetime.fromisoformat(item["last_seen"]),
|
|
price=item["price"],
|
|
)
|
|
for item in parsed
|
|
]
|
|
|
|
|
|
class Listing(SQLModel, table=False):
|
|
id: int = Field(primary_key=True)
|
|
price: float = Field(nullable=False, index=True)
|
|
number_of_bedrooms: int = Field(nullable=False, index=True)
|
|
square_meters: float | None = Field(default=None, nullable=True, index=True)
|
|
agency: str | None = Field(default=None, nullable=True)
|
|
council_tax_band: str | None = Field(default=None, nullable=True)
|
|
longitude: float = Field(nullable=False)
|
|
latitude: float = Field(nullable=False)
|
|
price_history_json: str = Field(sa_type=TEXT)
|
|
listing_site: ListingSite = Field(nullable=False)
|
|
last_seen: datetime = Field(
|
|
default_factory=datetime.now, nullable=False, index=True
|
|
)
|
|
photo_thumbnail: str | None = Field(default=None, nullable=True)
|
|
floorplan_image_paths: List[str] = Field(
|
|
default_factory=list, sa_type=JSON, nullable=False
|
|
)
|
|
additional_info: Dict[str, Any] = Field(
|
|
default_factory=dict, sa_type=JSON, nullable=False
|
|
)
|
|
routing_info_json: str = Field(
|
|
sa_type=TEXT, nullable=True, default=None
|
|
) # Store as JSON string for simplicity
|
|
|
|
# Per-listing price-trend snapshot maintained by the daily aggregator.
|
|
# `price_14d_ago` is the historical price ~14 days before the most recent
|
|
# aggregator run (sourced from price_history_json). `price_change_pct_14d`
|
|
# is the % change from that to the current `price` (positive=up, neg=down).
|
|
# Both are null when the listing has no entry that old in its history.
|
|
price_14d_ago: float | None = Field(default=None, nullable=True)
|
|
price_change_pct_14d: float | None = Field(default=None, nullable=True)
|
|
|
|
@property
|
|
def is_removed(self) -> bool:
|
|
if not self.additional_info:
|
|
return False
|
|
property_info = self.additional_info.get("property", {})
|
|
return not property_info.get("visible", True)
|
|
|
|
@property
|
|
def price_per_square_meter(self) -> float | None:
|
|
"""
|
|
Returns the price per square meter.
|
|
"""
|
|
if self.square_meters is None or self.square_meters == 0:
|
|
return None
|
|
return round(self.price / self.square_meters, 2)
|
|
|
|
@property
|
|
def url(self):
|
|
return f"https://www.rightmove.co.uk/properties/{self.id}"
|
|
|
|
@property
|
|
def price_history(self) -> List[PriceHistoryItem]:
|
|
"""
|
|
Returns a list of PriceHistoryItem objects from the price_history_json.
|
|
"""
|
|
return _parse_price_history(self.price_history_json)
|
|
|
|
@staticmethod
|
|
def serialize_price_history(price_history: List[PriceHistoryItem]) -> str:
|
|
"""
|
|
Serializes the price history to a JSON string.
|
|
"""
|
|
serialized = json.dumps(
|
|
[
|
|
{
|
|
"first_seen": item.first_seen.isoformat(),
|
|
"last_seen": item.last_seen.isoformat(),
|
|
"price": item.price,
|
|
}
|
|
for item in price_history
|
|
]
|
|
)
|
|
return serialized
|
|
|
|
@property
|
|
def routing_info(self) -> dict[DestinationMode, List[Route]]:
|
|
"""
|
|
Returns a list of DestinationMode objects from the routing_info_str.
|
|
"""
|
|
if not self.routing_info_json:
|
|
return {}
|
|
from rec.route_serializer import RouteSerializer
|
|
return RouteSerializer.deserialize(self.routing_info_json)
|
|
|
|
def serialize_routing_info(
|
|
self, routing_info: dict[DestinationMode, list[Route]]
|
|
) -> str:
|
|
"""
|
|
Serializes the routing_info to a JSON string.
|
|
"""
|
|
from rec.route_serializer import RouteSerializer
|
|
return RouteSerializer.serialize(routing_info)
|
|
|
|
|
|
class FurnishType(enum.StrEnum):
|
|
FURNISHED = "furnished"
|
|
UNFURNISHED = "unfurnished"
|
|
PART_FURNISHED = "part furnished"
|
|
ASK_LANDLORD = "ask landlord"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class RentListing(Listing, table=True):
|
|
available_from: datetime | None = Field(default=None, nullable=True)
|
|
furnish_type: FurnishType | None = Field(nullable=False)
|
|
|
|
|
|
class BuyListing(Listing, table=True):
|
|
service_charge: float | None = Field(default=None, nullable=True)
|
|
lease_left: int | None = Field(
|
|
default=None, nullable=True
|
|
) # in years, e.g., 90, 80, etc.
|
|
|
|
|
|
class DailyListingAggregate(SQLModel, table=True):
|
|
"""One row per (snapshot_date, listing_type, bedroom band).
|
|
|
|
Written daily by `compute_daily_market_aggregates_task` after the scrape
|
|
settles. Drives the `MarketTrendStrip` UI ("get a vibe of the market").
|
|
The (date, listing_type, min_bedrooms, max_bedrooms) tuple is unique;
|
|
the aggregator upserts rather than appends so re-running on the same day
|
|
refreshes the snapshot instead of duplicating it.
|
|
"""
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"snapshot_date", "listing_type", "min_bedrooms", "max_bedrooms",
|
|
name="uq_aggregate_date_filter",
|
|
),
|
|
)
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
snapshot_date: datetime = Field(nullable=False, index=True)
|
|
listing_type: str = Field(nullable=False) # "RENT" or "BUY"
|
|
min_bedrooms: int = Field(nullable=False)
|
|
max_bedrooms: int = Field(nullable=False)
|
|
listing_count: int = Field(nullable=False)
|
|
median_total_price: float | None = Field(default=None, nullable=True)
|
|
median_qmprice: float | None = Field(default=None, nullable=True)
|
|
mean_total_price: float | None = Field(default=None, nullable=True)
|
|
mean_qmprice: float | None = Field(default=None, nullable=True)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DestinationMode:
|
|
destination_address: str
|
|
travel_mode: routing.TravelMode
|
|
|
|
def __hash__(self) -> int:
|
|
return hash((self.destination_address, self.travel_mode))
|
|
|
|
def __getstate__(self):
|
|
# This allows serializers to pick up a dict representation
|
|
return asdict(self)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Return a dictionary representation of this DestinationMode."""
|
|
return asdict(self)
|
|
|
|
|
|
class ListingType(enum.StrEnum):
|
|
BUY = "BUY"
|
|
RENT = "RENT"
|
|
|
|
|
|
class QueryParameters(BaseModel):
|
|
"""Query parameters for filtering listings."""
|
|
model_config = {"frozen": True}
|
|
|
|
listing_type: ListingType
|
|
min_bedrooms: int = 1
|
|
max_bedrooms: int = 999
|
|
min_price: int = 0
|
|
max_price: int = 10_000_000
|
|
district_names: set[str] = PydanticField(default_factory=set)
|
|
radius: float = 0
|
|
page_size: int = 500 # items per page
|
|
max_days_since_added: int = 14 # for buy listings
|
|
furnish_types: list[FurnishType] | None = None
|
|
# The values below are not supported by rightmove
|
|
# hence we apply them after fetching
|
|
# available from; council tax
|
|
let_date_available_from: datetime | None = None
|
|
last_seen_days: int | None = None
|
|
min_sqm: int | None = None
|
|
max_sqm: int | None = None
|
|
min_price_per_sqm: float | None = None
|
|
max_price_per_sqm: float | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_ranges(self) -> QueryParameters:
|
|
if self.min_price > self.max_price:
|
|
raise ValueError(
|
|
f"min_price ({self.min_price}) must be <= max_price ({self.max_price})"
|
|
)
|
|
if self.min_bedrooms < 0:
|
|
raise ValueError(
|
|
f"min_bedrooms ({self.min_bedrooms}) must be non-negative"
|
|
)
|
|
if self.max_bedrooms < 0:
|
|
raise ValueError(
|
|
f"max_bedrooms ({self.max_bedrooms}) must be non-negative"
|
|
)
|
|
if self.min_bedrooms > self.max_bedrooms:
|
|
raise ValueError(
|
|
f"min_bedrooms ({self.min_bedrooms}) must be <= max_bedrooms ({self.max_bedrooms})"
|
|
)
|
|
if (
|
|
self.min_price_per_sqm is not None
|
|
and self.max_price_per_sqm is not None
|
|
and self.min_price_per_sqm > self.max_price_per_sqm
|
|
):
|
|
raise ValueError(
|
|
f"min_price_per_sqm ({self.min_price_per_sqm}) must be <= max_price_per_sqm ({self.max_price_per_sqm})"
|
|
)
|
|
if (
|
|
self.min_sqm is not None
|
|
and self.max_sqm is not None
|
|
and self.min_sqm > self.max_sqm
|
|
):
|
|
raise ValueError(
|
|
f"min_sqm ({self.min_sqm}) must be <= max_sqm ({self.max_sqm})"
|
|
)
|
|
return self
|