wrongmove/models/listing.py
Viktor Barzin 49e3514780 wrongmove: daily price-trend monitoring (per-listing badge + macro strip)
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>
2026-05-16 12:02:25 +00:00

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