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:
Viktor Barzin 2026-05-16 12:02:25 +00:00
parent c2e08fe46e
commit 49e3514780
16 changed files with 1069 additions and 1 deletions

View file

@ -11,6 +11,7 @@ import { Map } from './components/Map';
import { type ParameterValues, DEFAULT_FILTER_VALUES, Metric, ListingType } from './components/FilterPanel';
import { FilterBar, type FilterBarFormHandle } from './components/FilterBar';
import { FilterChips } from './components/FilterChips';
import { MarketTrendStrip } from './components/MarketTrendStrip';
import { VisualizationCard } from './components/VisualizationCard';
import { Header } from './components/Header';
import { StatsBar } from './components/StatsBar';
@ -856,6 +857,17 @@ function AppContent() {
/>
</div>
{/* Macro market-trend strip always visible, gives a "vibe of
the market" for the current filter's bedroom band. */}
{queryParameters && (
<MarketTrendStrip
user={user}
listingType={listingType}
minBedrooms={queryParameters.min_bedrooms ?? 1}
maxBedrooms={queryParameters.max_bedrooms ?? 2}
/>
)}
{/* Active Filter Chips */}
{queryParameters && (
<FilterChips

View file

@ -0,0 +1,95 @@
// Compact, always-visible inline strip showing macro market trend for the
// user's current filter scope (listing_type + bedroom band). Three metrics:
// median price, median £/m², listing count — each with a 30d delta. Lives
// above the filter chip strip so it's visible from anywhere in the app.
import { useMarketTrend, type MarketTrendDelta } from '@/hooks/useMarketTrend';
import { formatPrice, EM_DASH } from '@/utils/format';
import type { AuthUser } from '@/auth/types';
interface Props {
user: AuthUser | null;
listingType: 'RENT' | 'BUY';
minBedrooms: number;
maxBedrooms: number;
days?: number;
}
function DeltaLabel({ delta, kind }: { delta: MarketTrendDelta; kind: 'price' | 'count' | 'pricePerSqm' }) {
if (delta.current === null) return <span className="text-muted-foreground">{EM_DASH}</span>;
const formatted = (() => {
if (kind === 'price') return formatPrice(delta.current);
if (kind === 'pricePerSqm') return `£${Math.round(delta.current)}/m²`;
return delta.current.toLocaleString();
})();
if (delta.changePct === null || delta.previous === null) {
return <span><strong className="font-semibold">{formatted}</strong></span>;
}
// Drops in price/£m² are good (green); rises are red. For listing count
// direction has no inherent good/bad — just show the move neutrally.
const isPriceMetric = kind === 'price' || kind === 'pricePerSqm';
const dropped = delta.changePct < 0;
const colour = isPriceMetric
? (Math.abs(delta.changePct) < 0.1
? 'text-muted-foreground'
: dropped
? 'text-[var(--deal-good)]'
: 'text-[var(--deal-above)]')
: 'text-muted-foreground';
const arrow = Math.abs(delta.changePct) < 0.1 ? '·' : dropped ? '↓' : '↑';
const sign = delta.changePct > 0 ? '+' : '';
return (
<span>
<strong className="font-semibold">{formatted}</strong>{' '}
<span className={`text-xs ${colour}`} title={`vs ${delta.previous}`}>
{arrow} {sign}{delta.changePct}%
</span>
</span>
);
}
export function MarketTrendStrip({
user,
listingType,
minBedrooms,
maxBedrooms,
days = 30,
}: Props) {
const { series, isLoading, deltas } = useMarketTrend(
user,
listingType,
minBedrooms,
maxBedrooms,
days,
);
if (isLoading && series.length === 0) {
return (
<div className="flex items-center gap-3 px-3 py-1.5 text-xs text-muted-foreground border-b">
Loading market trend
</div>
);
}
// Hide entirely when we have no data at all (the daily aggregator hasn't
// produced any rows yet — common during the first week post-launch).
if (series.length === 0) return null;
const label = `${listingType === 'RENT' ? 'Rent' : 'Buy'} · ${minBedrooms}-${maxBedrooms} bed · ${days}d`;
return (
<div
className="flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 text-xs border-b bg-muted/30"
data-testid="market-trend-strip"
>
<span className="text-muted-foreground">{label}:</span>
<span>Median <DeltaLabel delta={deltas.median_total_price} kind="price" /></span>
<span>·</span>
<span>£/m² <DeltaLabel delta={deltas.median_qmprice} kind="pricePerSqm" /></span>
<span>·</span>
<span>Listings <DeltaLabel delta={deltas.listing_count} kind="count" /></span>
</div>
);
}

View file

@ -228,6 +228,28 @@ export function PropertyCard({
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
: null;
// Per-listing trend badge — surfaces when the daily aggregator has
// computed a non-trivial price move over the 14d lookback window.
// Threshold = 1% so we don't render noise from rounding or minor jitters.
const trendBadge = (() => {
const pct = property.price_change_pct_14d;
const past = property.price_14d_ago;
if (typeof pct !== 'number' || !isFiniteNumber(pct)) return null;
if (Math.abs(pct) < 1) return null;
const dropped = pct < 0;
const deltaAbs = isFiniteNumber(past) && isFiniteNumber(property.total_price)
? Math.abs(property.total_price - past)
: null;
return {
dropped,
label: `${dropped ? '↓' : '↑'} ${deltaAbs !== null ? formatPrice(deltaAbs) : ''} (${pct > 0 ? '+' : ''}${pct}%) in 14d`.replace(/\s+/g, ' ').trim(),
// Drops are good for the buyer; greens for drop, reds for rise.
className: dropped
? 'text-[var(--deal-good)] bg-[var(--deal-good)]/10 border-[var(--deal-good)]/40'
: 'text-[var(--deal-above)] bg-[var(--deal-above)]/10 border-[var(--deal-above)]/40',
};
})();
const handleClick = () => {
onClick?.();
};
@ -266,6 +288,14 @@ export function PropertyCard({
{priceIndicator && (
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
)}
{trendBadge && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${trendBadge.className}`}
title={trendBadge.label}
>
{trendBadge.label}
</span>
)}
</div>
{/* Key metrics on one line */}
@ -344,6 +374,14 @@ export function PropertyCard({
{priceIndicator && (
<span className="text-xs text-muted-foreground">{priceIndicator.label}</span>
)}
{trendBadge && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${trendBadge.className}`}
title={trendBadge.label}
>
{trendBadge.label}
</span>
)}
</div>
{/* Key metrics on one line */}

View file

@ -223,4 +223,51 @@ describe('PropertyCard', () => {
expect(container.querySelector('button[aria-label="Previous photo"]')).not.toBeInTheDocument();
expect(container.querySelector('button[aria-label="Next photo"]')).not.toBeInTheDocument();
});
// Price-trend badge — renders for moves >=1%, hidden for noise / nulls.
it('renders a "↓" trend badge when price dropped >1% in 14d', () => {
const property = {
...createMockProperty({ total_price: 2400 }),
price_14d_ago: 2500,
price_change_pct_14d: -4,
} as unknown as PropertyProperties;
const { container } = render(<PropertyCard property={property} />);
const text = container.textContent ?? '';
expect(text).toMatch(/↓/);
expect(text).toMatch(/-4%/);
expect(text).toMatch(/14d/);
});
it('renders a "↑" trend badge when price rose >1% in 14d', () => {
const property = {
...createMockProperty({ total_price: 2200 }),
price_14d_ago: 2000,
price_change_pct_14d: 10,
} as unknown as PropertyProperties;
const { container } = render(<PropertyCard property={property} />);
const text = container.textContent ?? '';
expect(text).toMatch(/↑/);
expect(text).toMatch(/\+10%/);
});
it('omits the trend badge when the move is < 1% (noise threshold)', () => {
const property = {
...createMockProperty({ total_price: 2510 }),
price_14d_ago: 2500,
price_change_pct_14d: 0.4,
} as unknown as PropertyProperties;
const { container } = render(<PropertyCard property={property} />);
expect(container.textContent).not.toMatch(/↑|↓/);
expect(container.textContent).not.toMatch(/14d/);
});
it('omits the trend badge when price_change_pct_14d is null', () => {
const property = {
...createMockProperty({ total_price: 2500 }),
price_14d_ago: null,
price_change_pct_14d: null,
} as unknown as PropertyProperties;
const { container } = render(<PropertyCard property={property} />);
expect(container.textContent).not.toMatch(/14d/);
});
});

View file

@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useMarketTrend } from '@/hooks/useMarketTrend';
import type { AuthUser } from '@/auth/types';
vi.mock('@/services/apiClient', () => ({
apiRequest: vi.fn(),
}));
import { apiRequest } from '@/services/apiClient';
const apiRequestMock = vi.mocked(apiRequest);
const user: AuthUser = {
sub: 'u',
email: 'a@b.com',
name: 'A',
accessToken: 'tok',
provider: 'oidc',
};
describe('useMarketTrend — series fetch + delta derivation', () => {
beforeEach(() => {
apiRequestMock.mockReset();
});
it('hits /api/market_trend with the requested filter params', async () => {
apiRequestMock.mockResolvedValue([]);
const { result } = renderHook(() => useMarketTrend(user, 'RENT', 1, 2, 30));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(apiRequestMock).toHaveBeenCalledWith(
user,
expect.stringContaining('/api/market_trend?'),
);
const url = apiRequestMock.mock.calls[0][1] as string;
expect(url).toContain('listing_type=RENT');
expect(url).toContain('min_bedrooms=1');
expect(url).toContain('max_bedrooms=2');
expect(url).toContain('days=30');
});
it('computes deltas between the oldest and newest in-window points', async () => {
apiRequestMock.mockResolvedValue([
{
snapshot_date: '2026-04-16',
listing_count: 1000,
median_total_price: 2500,
median_qmprice: 50,
mean_total_price: 2600,
mean_qmprice: 52,
},
{
snapshot_date: '2026-05-16',
listing_count: 1050,
median_total_price: 2400,
median_qmprice: 48,
mean_total_price: 2500,
mean_qmprice: 50,
},
]);
const { result } = renderHook(() => useMarketTrend(user, 'RENT', 1, 2, 30));
await waitFor(() => expect(result.current.series.length).toBe(2));
expect(result.current.deltas.median_total_price.current).toBe(2400);
expect(result.current.deltas.median_total_price.previous).toBe(2500);
// (2400 - 2500) / 2500 * 100 = -4
expect(result.current.deltas.median_total_price.changePct).toBe(-4);
expect(result.current.deltas.listing_count.changePct).toBe(5);
});
it('returns null changePct when there is only one datapoint', async () => {
apiRequestMock.mockResolvedValue([
{
snapshot_date: '2026-05-16',
listing_count: 1050,
median_total_price: 2400,
median_qmprice: 48,
mean_total_price: 2500,
mean_qmprice: 50,
},
]);
const { result } = renderHook(() => useMarketTrend(user, 'RENT', 1, 2, 30));
await waitFor(() => expect(result.current.series.length).toBe(1));
expect(result.current.deltas.median_total_price.changePct).toBeNull();
expect(result.current.deltas.median_total_price.current).toBe(2400);
});
it('returns empty series + null deltas when the endpoint is empty', async () => {
apiRequestMock.mockResolvedValue([]);
const { result } = renderHook(() => useMarketTrend(user, 'BUY', 1, 2, 30));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.series).toEqual([]);
expect(result.current.deltas.median_total_price.current).toBeNull();
expect(result.current.deltas.median_total_price.changePct).toBeNull();
});
});

View file

@ -0,0 +1,93 @@
// Fetches the daily market aggregate series for a given listing-type +
// bedroom band. Re-fetches when the inputs change. Returns the raw array
// of points plus a derived "now vs N days ago" delta the strip renders.
import { useEffect, useState } from 'react';
import type { AuthUser } from '@/auth/types';
import type { MarketTrendPoint } from '@/types';
import { apiRequest } from '@/services/apiClient';
export interface MarketTrendDelta {
metric: 'median_total_price' | 'median_qmprice' | 'listing_count';
current: number | null;
previous: number | null;
changePct: number | null;
}
export interface UseMarketTrendResult {
series: MarketTrendPoint[];
isLoading: boolean;
error: string | null;
// Convenience: today's value vs the oldest in-window value.
deltas: Record<MarketTrendDelta['metric'], MarketTrendDelta>;
}
function buildDelta(
metric: MarketTrendDelta['metric'],
series: MarketTrendPoint[],
): MarketTrendDelta {
if (series.length < 2) {
const only = series[0];
return {
metric,
current: only ? (only[metric] as number | null) : null,
previous: null,
changePct: null,
};
}
const current = series[series.length - 1][metric] as number | null;
const previous = series[0][metric] as number | null;
if (current === null || previous === null || previous === 0) {
return { metric, current, previous, changePct: null };
}
const changePct = Math.round(((current - previous) / previous) * 1000) / 10;
return { metric, current, previous, changePct };
}
export function useMarketTrend(
user: AuthUser | null,
listingType: 'RENT' | 'BUY',
minBedrooms: number,
maxBedrooms: number,
days: number = 30,
): UseMarketTrendResult {
const [series, setSeries] = useState<MarketTrendPoint[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
let cancelled = false;
setIsLoading(true);
setError(null);
const params = new URLSearchParams({
listing_type: listingType,
min_bedrooms: String(minBedrooms),
max_bedrooms: String(maxBedrooms),
days: String(days),
});
apiRequest<MarketTrendPoint[]>(user, `/api/market_trend?${params}`)
.then((data) => {
if (cancelled) return;
setSeries(data);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [user, listingType, minBedrooms, maxBedrooms, days]);
const deltas: UseMarketTrendResult['deltas'] = {
median_total_price: buildDelta('median_total_price', series),
median_qmprice: buildDelta('median_qmprice', series),
listing_count: buildDelta('listing_count', series),
};
return { series, isLoading, error, deltas };
}

View file

@ -24,6 +24,10 @@ export interface PropertyProperties {
price_history: PropertyPriceHistory[];
listing_type?: 'RENT' | 'BUY';
poi_distances?: POIDistanceInfo[];
// Trend snapshot maintained by the daily market aggregator (nullable
// until the aggregator has run at least once with this listing in scope).
price_14d_ago?: number | null;
price_change_pct_14d?: number | null;
}
export interface PropertyFeature {
@ -181,6 +185,16 @@ export interface WSPongMessage {
export type WSMessage = WSInitMessage | WSTaskUpdateMessage | WSPongMessage;
// One day of aggregated market stats — see /api/market_trend.
export interface MarketTrendPoint {
snapshot_date: string; // ISO date
listing_count: number;
median_total_price: number | null;
median_qmprice: number | null;
mean_total_price: number | null;
mean_qmprice: number | null;
}
// Decision types
// `seen` is a soft-hide: the listing is removed from the main list by default
// but can be re-revealed via the "Show hidden" filter toggle. Distinct from