Skip to content

Commit 6e0ec9c

Browse files
feat(analytics-service): add statistical price forecasting
- ForecastService: linear trend regression (numpy.polyfit) for direction + R-squared confidence, layered with SimpleExpSmoothing (statsmodels) for a realistic forecast center -- deliberate choice over deep learning given sparse, short price-history series (documented rationale) - New PriceForecast schema/endpoint (/analytics/{id}/forecast), additive to Phase 10's rule-based PriceInsights rather than replacing it -- cheap always-available heuristic + richer but data-hungry forecast - MIN_POINTS_FOR_FORECAST=5 gate (ForecastConfidence.INSUFFICIENT_DATA) mirrors Phase 10's MIN_POINTS_FOR_TREND honesty principle - Explanation string generated FROM the same slope/r_squared values returned in the schema, preventing text/data drift - pricelens_analytics_forecasts_total metric tracks confidence distribution over time (Phase 15/16 instrumentation pattern) - Frontend: forecast panel on WatchlistPage, gated on confidence != insufficient_data, fetched concurrently with history/insights
1 parent bb7be20 commit 6e0ec9c

8 files changed

Lines changed: 387 additions & 131 deletions

File tree

frontend/src/lib/api/analytics.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { authClient } from "./client";
2-
import type { PriceInsights } from "../../types";
1+
import { trackingClient } from "./client";
2+
import type { PriceForecast, PriceInsights } from "../../types";
33

44
export async function getInsights(productId: string): Promise<PriceInsights> {
5-
const response = await authClient.get<PriceInsights>(`/analytics/insights/${productId}`);
6-
return response.data;
5+
const { data } = await trackingClient.get<PriceInsights>(
6+
`/analytics/${productId}/insights`
7+
);
8+
return data;
9+
}
10+
11+
export async function getForecast(productId: string): Promise<PriceForecast> {
12+
const { data } = await trackingClient.get<PriceForecast>(
13+
`/analytics/${productId}/forecast`
14+
);
15+
return data;
716
}

frontend/src/pages/WatchlistPage.tsx

Lines changed: 108 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { Suspense, lazy, useEffect, useState } from "react";
22
import { getWatchlist, getPriceHistory, removeFromWatchlist } from "../lib/api/tracking";
3-
import { getInsights } from "../lib/api/analytics";
4-
import type { PriceHistoryPoint, PriceInsights, WatchlistItemOut } from "../types";
5-
3+
import { getForecast, getInsights } from "../lib/api/analytics";
4+
import type { PriceForecast, PriceHistoryPoint, PriceInsights, WatchlistItemOut } from "../types";
5+
6+
// React.lazy + dynamic import() splits recharts (and PriceHistoryChart's
7+
// code) into its OWN JS chunk, downloaded only when this component
8+
// actually renders -- i.e. only after a user logs in, opens the
9+
// watchlist, and clicks "View trend." Every other page load (anonymous
10+
// search, login, register) never downloads this chunk at all. This is
11+
// the direct fix for the 630KB bundle warning flagged back in Phase 11.
612
const PriceHistoryChart = lazy(() => import("../components/PriceHistoryChart"));
713

814
const TREND_LABEL: Record<string, string> = {
@@ -12,6 +18,11 @@ const TREND_LABEL: Record<string, string> = {
1218
insufficient_data: "Not enough data yet",
1319
};
1420

21+
// Maps each recommendation/confidence value to one of the three semantic
22+
// badge classes defined once in index.css (badge-success / badge-warning
23+
// / badge-neutral) -- the visual meaning (green=good, amber=caution,
24+
// gray=neutral) is now defined in exactly one place instead of being
25+
// re-derived as a long Tailwind class string per value, per page.
1526
const RECOMMENDATION_BADGE_CLASS: Record<string, string> = {
1627
good_time_to_buy: "badge-success",
1728
wait: "badge-warning",
@@ -24,6 +35,12 @@ const RECOMMENDATION_LABEL: Record<string, string> = {
2435
neutral: "No strong signal",
2536
};
2637

38+
const CONFIDENCE_BADGE_CLASS: Record<string, string> = {
39+
high: "badge-success",
40+
moderate: "badge-warning",
41+
low: "badge-neutral",
42+
};
43+
2744
function WatchlistItemSkeleton() {
2845
return (
2946
<div className="card p-5">
@@ -48,66 +65,42 @@ export function WatchlistPage() {
4865
const [expandedId, setExpandedId] = useState<string | null>(null);
4966
const [history, setHistory] = useState<PriceHistoryPoint[]>([]);
5067
const [insights, setInsights] = useState<PriceInsights | null>(null);
68+
const [forecast, setForecast] = useState<PriceForecast | null>(null);
5169
const [isDetailLoading, setIsDetailLoading] = useState(false);
5270

5371
useEffect(() => {
5472
getWatchlist()
55-
.then((data) => {
56-
setItems(Array.isArray(data) ? data : []);
57-
})
58-
.catch((err) => console.error("Error loading watchlist:", err))
73+
.then(setItems)
5974
.finally(() => setIsLoading(false));
6075
}, []);
6176

62-
async function handleExpand(product_id: string) {
63-
if (!product_id) return;
64-
65-
if (expandedId === product_id) {
77+
async function handleExpand(item: WatchlistItemOut) {
78+
if (expandedId === item.product.id) {
6679
setExpandedId(null);
6780
return;
6881
}
69-
70-
setExpandedId(product_id);
82+
setExpandedId(item.product.id);
7183
setIsDetailLoading(true);
7284
try {
73-
const [historyData, insightsData] = await Promise.all([
74-
getPriceHistory(product_id),
75-
getInsights(product_id).catch(() => null),
85+
const [historyData, insightsData, forecastData] = await Promise.all([
86+
getPriceHistory(item.product.id),
87+
getInsights(item.product.id),
88+
getForecast(item.product.id),
7689
]);
77-
setHistory(historyData || []);
90+
setHistory(historyData);
7891
setInsights(insightsData);
79-
} catch (err) {
80-
console.error("Error loading historic tracking trend metrics:", err);
92+
setForecast(forecastData);
8193
} finally {
8294
setIsDetailLoading(false);
8395
}
8496
}
8597

86-
async function handleRemove(item: any) {
87-
// 🎯 TARGET THE TEXT-BASED PRODUCT_ID (e.g., 'amazon-0' or custom string slug)
88-
const targetId = item.product_id || item.product?.id || item.id;
89-
90-
if (!targetId) {
91-
console.warn("Remove action dropped: Missing product identifier string.");
92-
return;
93-
}
94-
95-
try {
96-
await removeFromWatchlist(targetId);
97-
98-
// Filter out only the singular item matching our string target ID
99-
setItems((prev) =>
100-
prev.filter((i: any) => {
101-
const currentId = i.product_id || i.product?.id || i.id;
102-
return currentId !== targetId;
103-
})
104-
);
105-
106-
if (expandedId === targetId) setExpandedId(null);
107-
} catch (err) {
108-
console.error("Failed to remove item from backend watchlist:", err);
109-
}
98+
async function handleRemove(productId: string) {
99+
await removeFromWatchlist(productId);
100+
setItems((prev) => prev.filter((item) => item.product.id !== productId));
101+
if (expandedId === productId) setExpandedId(null);
110102
}
103+
111104
if (isLoading) {
112105
return (
113106
<div className="page-enter space-y-3">
@@ -136,75 +129,83 @@ export function WatchlistPage() {
136129
</p>
137130

138131
<div className="mt-6 space-y-3">
139-
{items.map((item: any) => {
140-
const trackingProductId = item.product_id || item.product?.id || item.id;
141-
const title = item.title || item.product?.title || "Tracked Product";
142-
const platform = item.platform || item.product?.platform || "unknown";
143-
const currency = item.currency || item.product?.currency || "INR";
144-
const displayPrice = item.price ?? item.latest_price ?? item.product?.latest_price ?? item.product?.price ?? 0;
145-
146-
return (
147-
<div key={item.id} className="card">
148-
<div className="flex items-center justify-between p-5">
149-
<div>
150-
<span className="badge badge-neutral capitalize">{platform}</span>
151-
<h3 className="mt-2 text-sm font-medium">{title}</h3>
152-
<p className="mt-1 text-lg font-semibold">
153-
{currency} {displayPrice.toLocaleString()}
154-
</p>
155-
</div>
156-
<div className="flex gap-2">
157-
<button onClick={() => handleExpand(trackingProductId)} className="btn-secondary">
158-
{expandedId === trackingProductId ? "Hide" : "View trend"}
159-
</button>
160-
<button onClick={() => handleRemove(item)} className="btn-danger-ghost">
161-
Remove
162-
</button>
163-
</div>
132+
{items.map((item) => (
133+
<div key={item.id} className="card">
134+
<div className="flex items-center justify-between p-5">
135+
<div>
136+
<span className="badge badge-neutral capitalize">{item.product.platform}</span>
137+
<h3 className="mt-2 text-sm font-medium">{item.product.title}</h3>
138+
<p className="mt-1 text-lg font-semibold">
139+
{item.product.currency} {item.product.latest_price.toLocaleString()}
140+
</p>
141+
</div>
142+
<div className="flex gap-2">
143+
<button onClick={() => handleExpand(item)} className="btn-secondary">
144+
{expandedId === item.product.id ? "Hide" : "View trend"}
145+
</button>
146+
<button onClick={() => handleRemove(item.product.id)} className="btn-danger-ghost">
147+
Remove
148+
</button>
164149
</div>
150+
</div>
165151

166-
{expandedId === trackingProductId && (
167-
<div className="border-t border-slate-100 p-5 dark:border-slate-800">
168-
{isDetailLoading ? (
169-
<div className="space-y-2">
170-
<div className="skeleton h-4 w-1/3" />
171-
<div className="skeleton h-[220px] w-full" />
172-
</div>
173-
) : (
174-
<>
175-
{insights && (
176-
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
177-
<span className={`badge ${RECOMMENDATION_BADGE_CLASS[insights.recommendation]}`}>
178-
{RECOMMENDATION_LABEL[insights.recommendation]}
179-
</span>
152+
{expandedId === item.product.id && (
153+
<div className="border-t border-slate-100 p-5 dark:border-slate-800">
154+
{isDetailLoading ? (
155+
<div className="space-y-2">
156+
<div className="skeleton h-4 w-1/3" />
157+
<div className="skeleton h-[220px] w-full" />
158+
</div>
159+
) : (
160+
<>
161+
{insights && (
162+
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
163+
<span className={`badge ${RECOMMENDATION_BADGE_CLASS[insights.recommendation]}`}>
164+
{RECOMMENDATION_LABEL[insights.recommendation]}
165+
</span>
166+
<span className="text-slate-500 dark:text-slate-400">
167+
Trend: {TREND_LABEL[insights.trend]}
168+
</span>
169+
{insights.lowest_price !== null && (
180170
<span className="text-slate-500 dark:text-slate-400">
181-
Trend: {TREND_LABEL[insights.trend]}
171+
Lowest seen: {item.product.currency} {insights.lowest_price.toLocaleString()}
172+
</span>
173+
)}
174+
</div>
175+
)}
176+
177+
{forecast && forecast.confidence !== "insufficient_data" && (
178+
<div className="mb-4 rounded-lg bg-brand-50 p-3 text-sm dark:bg-brand-900/20">
179+
<div className="mb-1 flex items-center gap-2">
180+
<span className="font-medium text-brand-700 dark:text-brand-300">
181+
7-day forecast
182+
</span>
183+
<span className={`badge ${CONFIDENCE_BADGE_CLASS[forecast.confidence]}`}>
184+
{forecast.confidence} confidence
182185
</span>
183-
{insights.lowest_price !== null && (
184-
<span className="text-slate-500 dark:text-slate-400">
185-
Lowest seen: {currency} {insights.lowest_price.toLocaleString()}
186-
</span>
187-
)}
188186
</div>
189-
)}
190-
191-
{history && history.length > 1 ? (
192-
<Suspense fallback={<div className="skeleton h-[220px] w-full" />}>
193-
<PriceHistoryChart history={history} currency={currency} />
194-
</Suspense>
195-
) : (
196-
<p className="text-sm text-slate-400">
197-
Not enough price history yet to plot a trend -- check back after a few
198-
more price checks.
199-
</p>
200-
)}
201-
</>
202-
)}
203-
</div>
204-
)}
205-
</div>
206-
);
207-
})}
187+
<p className="text-slate-600 dark:text-slate-300">{forecast.explanation}</p>
188+
</div>
189+
)}
190+
191+
{history.length > 1 ? (
192+
<Suspense
193+
fallback={<div className="skeleton h-[220px] w-full" />}
194+
>
195+
<PriceHistoryChart history={history} currency={item.product.currency} />
196+
</Suspense>
197+
) : (
198+
<p className="text-sm text-slate-400">
199+
Not enough price history yet to plot a trend -- check back after a few
200+
more price checks.
201+
</p>
202+
)}
203+
</>
204+
)}
205+
</div>
206+
)}
207+
</div>
208+
))}
208209
</div>
209210
</div>
210211
);

frontend/src/types/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export interface PriceInsights {
2323
recommendation: "good_time_to_buy" | "wait" | "neutral";
2424
}
2525

26+
export interface PriceForecast {
27+
confidence: "high" | "moderate" | "low" | "insufficient_data";
28+
explanation: string;
29+
}
30+
2631
export interface UserOut {
2732
id: string;
2833
email: string;
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
from fastapi import APIRouter
2-
3-
router = APIRouter(tags=["forecast"])
4-
5-
@router.get("/forecast/{product_id}")
6-
async def get_product_forecast(product_id: str):
7-
return {
8-
"product_id": product_id,
9-
"forecast_strategy": "baseline_linear_regression",
10-
"trend": "stable",
11-
"predicted_price_next_week": 0.0,
12-
"message": "Forecasting engine placeholder initialized successfully."
13-
}
1+
import uuid
2+
3+
from fastapi import APIRouter, HTTPException, status
4+
5+
from app.clients.tracking_client import TrackingServiceError
6+
from app.schemas.forecast import PriceForecast
7+
from app.services.forecast_service import ForecastService
8+
9+
router = APIRouter(prefix="/analytics", tags=["analytics"])
10+
11+
12+
@router.get("/{product_id}/forecast", response_model=PriceForecast)
13+
async def get_price_forecast(product_id: uuid.UUID):
14+
service = ForecastService()
15+
try:
16+
return await service.get_forecast(product_id)
17+
except TrackingServiceError as exc:
18+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
from prometheus_client import Counter
22

3-
# 📈 Tracks the distribution of user buying advice to find threshold calibrations
43
recommendations_total = Counter(
54
"pricelens_analytics_recommendations_total",
65
"Total buy recommendations issued, labeled by recommendation type",
7-
["recommendation"], # Allowed labels: "good_time_to_buy", "wait", "neutral"
6+
["recommendation"], # "good_time_to_buy", "wait", "neutral"
87
)
98

10-
# 🔮 Tracks model reliability and data completeness thresholds
119
forecasts_total = Counter(
1210
"pricelens_analytics_forecasts_total",
1311
"Total price forecasts generated, labeled by confidence level",
14-
["confidence"], # Allowed labels: "high", "moderate", "low", "insufficient_data"
12+
["confidence"], # "high", "moderate", "low", "insufficient_data"
1513
)

services/analytics-service/app/main.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
1010
from app.core.config import settings
1111
from app.core.logging_config import configure_logging, RequestIdMiddleware
1212

13+
# Configure structured logging
1314
configure_logging()
1415

15-
app = FastAPI(title=settings.app_name, debug=settings.debug)
16+
app = FastAPI(
17+
title=settings.app_name,
18+
debug=settings.debug
19+
)
1620

17-
# Use add_middleware for class-based BaseHTTPMiddleware definitions
21+
# Custom middleware for request tracing
1822
app.add_middleware(RequestIdMiddleware)
1923

24+
# CORS configuration
2025
app.add_middleware(
2126
CORSMiddleware,
2227
allow_origins=settings.cors_allowed_origins,
@@ -25,13 +30,15 @@
2530
allow_headers=["*"],
2631
)
2732

33+
# Registering routers
2834
app.include_router(health.router, prefix=settings.api_v1_prefix)
2935
app.include_router(analytics.router, prefix=settings.api_v1_prefix)
3036
app.include_router(forecast.router, prefix=settings.api_v1_prefix)
3137

38+
# Prometheus instrumentation
3239
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
3340

3441

3542
@app.get("/")
3643
async def root() -> dict[str, str]:
37-
return {"service": settings.app_name, "status": "running"}
44+
return {"service": settings.app_name, "status": "running"}

0 commit comments

Comments
 (0)