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>
This commit is contained in:
parent
c2e08fe46e
commit
49e3514780
16 changed files with 1069 additions and 1 deletions
163
tests/unit/test_market_aggregator.py
Normal file
163
tests/unit/test_market_aggregator.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Unit tests for the daily market aggregator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlmodel import SQLModel, Session
|
||||
|
||||
from models.listing import (
|
||||
DailyListingAggregate,
|
||||
FurnishType,
|
||||
Listing,
|
||||
ListingSite,
|
||||
RentListing,
|
||||
)
|
||||
from services import market_aggregator
|
||||
|
||||
|
||||
# --- compute_trend_for_listing --------------------------------------------
|
||||
|
||||
def _hist(entries: list[tuple[datetime, float]]) -> str:
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"first_seen": fs.isoformat(),
|
||||
"last_seen": fs.isoformat(),
|
||||
"price": p,
|
||||
}
|
||||
for fs, p in entries
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestComputeTrendForListing:
|
||||
def test_null_history_returns_nones(self) -> None:
|
||||
past, pct = market_aggregator.compute_trend_for_listing(None, 2500)
|
||||
assert past is None and pct is None
|
||||
|
||||
def test_empty_history_returns_nones(self) -> None:
|
||||
past, pct = market_aggregator.compute_trend_for_listing("[]", 2500)
|
||||
assert past is None and pct is None
|
||||
|
||||
def test_malformed_history_returns_nones(self) -> None:
|
||||
past, pct = market_aggregator.compute_trend_for_listing("not json", 2500)
|
||||
assert past is None and pct is None
|
||||
|
||||
def test_history_only_recent_returns_nones(self) -> None:
|
||||
"""History exists but no entry old enough."""
|
||||
now = datetime(2026, 5, 16, 12, 0, 0)
|
||||
history = _hist([(now - timedelta(days=2), 2500)])
|
||||
past, pct = market_aggregator.compute_trend_for_listing(
|
||||
history, 2500, lookback_days=14, now=now,
|
||||
)
|
||||
assert past is None and pct is None
|
||||
|
||||
def test_price_dropped(self) -> None:
|
||||
now = datetime(2026, 5, 16, 12, 0, 0)
|
||||
history = _hist([
|
||||
(now - timedelta(days=30), 2800),
|
||||
(now - timedelta(days=20), 2700),
|
||||
(now - timedelta(days=10), 2500),
|
||||
])
|
||||
# Lookback 14d → cutoff at day -14, latest entry on/before is day -20 (price 2700).
|
||||
past, pct = market_aggregator.compute_trend_for_listing(
|
||||
history, 2500, lookback_days=14, now=now,
|
||||
)
|
||||
assert past == 2700
|
||||
assert pct == round((2500 - 2700) / 2700 * 100, 2)
|
||||
assert pct < 0
|
||||
|
||||
def test_price_rose(self) -> None:
|
||||
now = datetime(2026, 5, 16, 12, 0, 0)
|
||||
history = _hist([(now - timedelta(days=20), 2000)])
|
||||
past, pct = market_aggregator.compute_trend_for_listing(
|
||||
history, 2200, lookback_days=14, now=now,
|
||||
)
|
||||
assert past == 2000
|
||||
assert pct == 10.0
|
||||
|
||||
def test_current_price_zero_returns_nones(self) -> None:
|
||||
past, pct = market_aggregator.compute_trend_for_listing(
|
||||
_hist([(datetime(2026, 1, 1), 2500)]), 0,
|
||||
)
|
||||
assert past is None and pct is None
|
||||
|
||||
|
||||
# --- _stats ----------------------------------------------------------------
|
||||
|
||||
class TestStats:
|
||||
def test_empty(self) -> None:
|
||||
out = market_aggregator._stats([])
|
||||
assert out == {"median": None, "mean": None, "count": 0}
|
||||
|
||||
def test_filters_nonpositive(self) -> None:
|
||||
out = market_aggregator._stats([0, -1, None, 2000, 3000]) # type: ignore[list-item]
|
||||
assert out["count"] == 2
|
||||
assert out["median"] == 2500
|
||||
assert out["mean"] == 2500.0
|
||||
|
||||
def test_single_value(self) -> None:
|
||||
out = market_aggregator._stats([1500])
|
||||
assert out == {"median": 1500.0, "mean": 1500.0, "count": 1}
|
||||
|
||||
|
||||
# --- compute_aggregate_snapshot — integration on SQLite ------------------
|
||||
|
||||
@pytest.fixture
|
||||
def engine_with_seed():
|
||||
"""In-memory SQLite seeded with a tiny RentListing set in the 1-2 bed band."""
|
||||
engine = create_engine("sqlite://")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
for i, (rooms, price, sqm) in enumerate(
|
||||
[(1, 2000, 40), (1, 2500, 50), (2, 3000, 60), (2, 4000, 80), (3, 5000, 100)],
|
||||
start=1,
|
||||
):
|
||||
session.add(
|
||||
RentListing(
|
||||
id=i, price=price, number_of_bedrooms=rooms,
|
||||
square_meters=sqm, longitude=0.0, latitude=0.0,
|
||||
price_history_json="[]", listing_site=ListingSite.RIGHTMOVE,
|
||||
last_seen=datetime(2026, 5, 16),
|
||||
furnish_type=FurnishType.UNKNOWN,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return engine
|
||||
|
||||
|
||||
class TestComputeAggregateSnapshot:
|
||||
def test_writes_one_row_per_band(self, engine_with_seed) -> None:
|
||||
rows = market_aggregator.compute_aggregate_snapshot(
|
||||
engine_with_seed,
|
||||
listing_types=("RENT",),
|
||||
bedroom_bands=((1, 2),),
|
||||
snapshot_date=datetime(2026, 5, 16),
|
||||
)
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.listing_count == 4 # excludes the 3-bed
|
||||
assert row.median_total_price == 2750.0 # median of [2000,2500,3000,4000]
|
||||
assert row.mean_total_price == 2875.0
|
||||
# qmprice values: 50, 50, 50, 50 → median 50
|
||||
assert row.median_qmprice == 50.0
|
||||
|
||||
def test_upsert_idempotent(self, engine_with_seed) -> None:
|
||||
market_aggregator.compute_aggregate_snapshot(
|
||||
engine_with_seed,
|
||||
listing_types=("RENT",),
|
||||
bedroom_bands=((1, 2),),
|
||||
snapshot_date=datetime(2026, 5, 16),
|
||||
)
|
||||
market_aggregator.compute_aggregate_snapshot(
|
||||
engine_with_seed,
|
||||
listing_types=("RENT",),
|
||||
bedroom_bands=((1, 2),),
|
||||
snapshot_date=datetime(2026, 5, 16),
|
||||
)
|
||||
with Session(engine_with_seed) as session:
|
||||
count = session.query(DailyListingAggregate).count()
|
||||
assert count == 1 # no duplicate row
|
||||
Loading…
Add table
Add a link
Reference in a new issue