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
|
|
@ -0,0 +1,68 @@
|
|||
"""add price trend columns and daily market aggregate table
|
||||
|
||||
Revision ID: a8b9c0d1e2f3
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-05-16 12:00:00.000000
|
||||
|
||||
Wires the price-monitoring feature:
|
||||
- Per-listing trend columns (`price_14d_ago`, `price_change_pct_14d`) on
|
||||
RentListing and BuyListing. Both are nullable — they stay empty for
|
||||
listings with no entry that old in their price_history_json.
|
||||
- A new `dailylistingaggregate` table keyed on
|
||||
(snapshot_date, listing_type, min_bedrooms, max_bedrooms) with median /
|
||||
mean / count for the daily-filter scope. One row per day per band.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8b9c0d1e2f3'
|
||||
down_revision: Union[str, None] = 'f7a8b9c0d1e2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("rentlisting", "buylisting"):
|
||||
op.add_column(table, sa.Column("price_14d_ago", sa.Float(), nullable=True))
|
||||
op.add_column(
|
||||
table, sa.Column("price_change_pct_14d", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"dailylistingaggregate",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("snapshot_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("listing_type", sa.String(length=8), nullable=False),
|
||||
sa.Column("min_bedrooms", sa.Integer(), nullable=False),
|
||||
sa.Column("max_bedrooms", sa.Integer(), nullable=False),
|
||||
sa.Column("listing_count", sa.Integer(), nullable=False),
|
||||
sa.Column("median_total_price", sa.Float(), nullable=True),
|
||||
sa.Column("median_qmprice", sa.Float(), nullable=True),
|
||||
sa.Column("mean_total_price", sa.Float(), nullable=True),
|
||||
sa.Column("mean_qmprice", sa.Float(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"snapshot_date", "listing_type", "min_bedrooms", "max_bedrooms",
|
||||
name="uq_aggregate_date_filter",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_dailylistingaggregate_snapshot_date",
|
||||
"dailylistingaggregate",
|
||||
["snapshot_date"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_dailylistingaggregate_snapshot_date",
|
||||
table_name="dailylistingaggregate",
|
||||
)
|
||||
op.drop_table("dailylistingaggregate")
|
||||
for table in ("rentlisting", "buylisting"):
|
||||
op.drop_column(table, "price_change_pct_14d")
|
||||
op.drop_column(table, "price_14d_ago")
|
||||
Loading…
Add table
Add a link
Reference in a new issue