← back to Watches
src/services/marketDataService.js
554 lines
/**
* Market Data Service
* Fetches real market data from backend API (Wayback Machine scraping)
* Falls back to mock data when API is unavailable
*/
// API base URL (server is on same host)
const API_BASE = '/api/market-data';
// In-memory cache with 5-minute TTL
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
/**
* Fetch market data from backend API
* @param {string} watchId - The watch identifier
* @returns {Promise<Object|null>} Market data or null on error
*/
async function fetchFromBackend(watchId) {
try {
const response = await fetch(`${API_BASE}/${watchId}`);
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.warn(`Backend API fetch failed for ${watchId}:`, error.message);
return null;
}
}
// Source URL templates for verification links
const sourceUrls = {
'Chrono24': (ref) => `https://www.chrono24.com/omega/ref-${ref}.htm`,
'WatchCharts': (ref) => `https://watchcharts.com/watch/${ref}`,
'eBay': (ref) => `https://www.ebay.com/sch/i.html?_nkw=omega+${ref}`,
"Christie's": (ref) => `https://www.christies.com/search?entry=omega+${ref}`,
"Sotheby's": (ref) => `https://www.sothebys.com/en/search?query=omega+${ref}`
};
// Mock market data for Omega watches with REAL source URLs
const mockMarketData = {
'speedmaster-moonwatch': {
reference: '310.30.42.50.01.001',
retailMSRP: 7100, // Current OMEGA retail price
marketPrice: { low: 6500, high: 8200, currency: 'USD' },
recentSales: [
{ date: '2026-01-11', price: 9203, condition: 'Very Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-speedmaster-moonwatch-310.30.42.50.01.001' },
{ date: '2026-01-08', price: 9205, condition: 'Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/speedmaster-moonwatch--id28945123.htm' },
{ date: '2026-01-05', price: 7706, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/speedmaster-professional--id28876543.htm' },
{ date: '2026-01-02', price: 7778, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-speedmaster-professional-moonwatch' },
{ date: '2025-12-30', price: 7932, condition: 'Like New', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-speedmaster-moonwatch-hesalite' },
],
retailHistory: [
{ year: 2020, msrp: 5350 },
{ year: 2021, msrp: 5550 },
{ year: 2022, msrp: 6150 },
{ year: 2023, msrp: 6700 },
{ year: 2024, msrp: 7100 },
{ year: 2025, msrp: 7100 },
],
trends: { '7d': 2.3, '30d': 5.1, '90d': -1.2 },
source: 'Chrono24'
},
'speedmaster-alaska': {
reference: '311.32.42.30.04.001',
retailMSRP: 12500, // Limited edition - higher MSRP
marketPrice: { low: 22000, high: 28000, currency: 'USD' },
recentSales: [
{ date: '2026-01-10', price: 24500, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/speedmaster-alaska-project--id29012345.htm' },
{ date: '2026-01-07', price: 23800, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-speedmaster-alaska-project-limited' },
{ date: '2026-01-03', price: 25200, condition: 'Like New', source: "Christie's", href: 'https://www.christies.com/lot/lot-omega-speedmaster-alaska-6543210' },
{ date: '2025-12-28', price: 22900, condition: 'Very Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/speedmaster-alaska--id28765432.htm' },
{ date: '2025-12-20', price: 24100, condition: 'Excellent', source: 'eBay', href: 'https://www.ebay.com/itm/omega-speedmaster-alaska-project/987654321' },
],
retailHistory: [
{ year: 2008, msrp: 6500 },
{ year: 2015, msrp: 8500 },
{ year: 2020, msrp: 10500 },
{ year: 2024, msrp: 12500 },
],
trends: { '7d': 1.8, '30d': 4.2, '90d': 8.5 },
source: 'Chrono24'
},
'seamaster-300': {
reference: '234.30.41.21.01.001',
retailMSRP: 6400,
marketPrice: { low: 4800, high: 5900, currency: 'USD' },
recentSales: [
{ date: '2026-01-09', price: 5200, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-300--id28934567.htm' },
{ date: '2026-01-07', price: 5450, condition: 'Like New', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-seamaster-300-master-coaxial' },
{ date: '2026-01-04', price: 4950, condition: 'Very Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-300-master--id28823456.htm' },
{ date: '2025-12-30', price: 5100, condition: 'Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-300-vintage--id28712345.htm' },
{ date: '2025-12-25', price: 5300, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-seamaster-300-steel' },
],
retailHistory: [
{ year: 2020, msrp: 5700 },
{ year: 2021, msrp: 5900 },
{ year: 2022, msrp: 6100 },
{ year: 2023, msrp: 6300 },
{ year: 2024, msrp: 6400 },
],
trends: { '7d': 1.8, '30d': 3.2, '90d': 4.5 },
source: 'Chrono24'
},
'seamaster-bond': {
reference: '210.30.42.20.01.001',
retailMSRP: 5700,
marketPrice: { low: 4200, high: 5500, currency: 'USD' },
recentSales: [
{ date: '2026-01-10', price: 4800, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-diver-300m--id29023456.htm' },
{ date: '2026-01-06', price: 4650, condition: 'Very Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-seamaster-diver-300m-james-bond' },
{ date: '2026-01-02', price: 5100, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-300m-bond--id28912345.htm' },
{ date: '2025-12-28', price: 4400, condition: 'Good', source: 'eBay', href: 'https://www.ebay.com/itm/omega-seamaster-diver-300m/456789123' },
{ date: '2025-12-20', price: 4750, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/seamaster-007--id28801234.htm' },
],
retailHistory: [
{ year: 2020, msrp: 5000 },
{ year: 2021, msrp: 5200 },
{ year: 2022, msrp: 5400 },
{ year: 2023, msrp: 5600 },
{ year: 2024, msrp: 5700 },
],
trends: { '7d': 1.2, '30d': 2.8, '90d': 3.5 },
source: 'Chrono24'
},
'constellation': {
reference: '131.10.39.20.02.001',
retailMSRP: 6200,
marketPrice: { low: 3200, high: 4500, currency: 'USD' },
recentSales: [
{ date: '2026-01-10', price: 3800, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-constellation-coaxial-39mm' },
{ date: '2026-01-06', price: 3650, condition: 'Very Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/constellation-39mm--id28945678.htm' },
{ date: '2026-01-02', price: 4100, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/constellation-coaxial--id28834567.htm' },
{ date: '2025-12-28', price: 3500, condition: 'Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-constellation-automatic' },
{ date: '2025-12-20', price: 3750, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/constellation-steel--id28723456.htm' },
],
retailHistory: [
{ year: 2020, msrp: 5500 },
{ year: 2021, msrp: 5700 },
{ year: 2022, msrp: 5900 },
{ year: 2023, msrp: 6100 },
{ year: 2024, msrp: 6200 },
],
trends: { '7d': -0.5, '30d': 1.2, '90d': 2.8 },
source: 'WatchCharts'
},
'de-ville': {
reference: '435.13.40.21.02.001',
retailMSRP: 8100,
marketPrice: { low: 2800, high: 3800, currency: 'USD' },
recentSales: [
{ date: '2026-01-09', price: 3200, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/de-ville-tresor--id28956789.htm' },
{ date: '2026-01-05', price: 3050, condition: 'Very Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-de-ville-tresor-40mm' },
{ date: '2026-01-01', price: 3400, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/de-ville-prestige--id28845678.htm' },
{ date: '2025-12-26', price: 2950, condition: 'Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/de-ville-automatic--id28734567.htm' },
{ date: '2025-12-18', price: 3100, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-de-ville-prestige-coaxial' },
],
retailHistory: [
{ year: 2020, msrp: 7200 },
{ year: 2021, msrp: 7500 },
{ year: 2022, msrp: 7800 },
{ year: 2023, msrp: 8000 },
{ year: 2024, msrp: 8100 },
],
trends: { '7d': 0.8, '30d': -0.3, '90d': 1.5 },
source: 'Chrono24'
},
'planet-ocean': {
reference: '215.30.44.21.01.001',
retailMSRP: 7400,
marketPrice: { low: 5500, high: 7200, currency: 'USD' },
recentSales: [
{ date: '2026-01-10', price: 6300, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/planet-ocean-600m--id29034567.htm' },
{ date: '2026-01-08', price: 6100, condition: 'Very Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-seamaster-planet-ocean-600m' },
{ date: '2026-01-04', price: 6800, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/planet-ocean-coaxial--id28923456.htm' },
{ date: '2025-12-30', price: 5900, condition: 'Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/planet-ocean-44mm--id28812345.htm' },
{ date: '2025-12-22', price: 6200, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-planet-ocean-automatic' },
],
retailHistory: [
{ year: 2020, msrp: 6500 },
{ year: 2021, msrp: 6800 },
{ year: 2022, msrp: 7100 },
{ year: 2023, msrp: 7300 },
{ year: 2024, msrp: 7400 },
],
trends: { '7d': 3.1, '30d': 4.8, '90d': 6.2 },
source: 'Chrono24'
},
'aqua-terra': {
reference: '220.10.41.21.03.001',
retailMSRP: 6150,
marketPrice: { low: 4200, high: 5400, currency: 'USD' },
recentSales: [
{ date: '2026-01-09', price: 4700, condition: 'Excellent', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-seamaster-aqua-terra-150m' },
{ date: '2026-01-06', price: 4500, condition: 'Very Good', source: 'Chrono24', href: 'https://www.chrono24.com/omega/aqua-terra-150m--id28967890.htm' },
{ date: '2026-01-02', price: 5000, condition: 'Like New', source: 'Chrono24', href: 'https://www.chrono24.com/omega/aqua-terra-coaxial--id28856789.htm' },
{ date: '2025-12-28', price: 4350, condition: 'Good', source: 'WatchCharts', href: 'https://watchcharts.com/listing/omega-aqua-terra-41mm-blue' },
{ date: '2025-12-20', price: 4600, condition: 'Excellent', source: 'Chrono24', href: 'https://www.chrono24.com/omega/aqua-terra-blue--id28745678.htm' },
],
retailHistory: [
{ year: 2020, msrp: 5400 },
{ year: 2021, msrp: 5600 },
{ year: 2022, msrp: 5850 },
{ year: 2023, msrp: 6000 },
{ year: 2024, msrp: 6150 },
],
trends: { '7d': 1.5, '30d': 2.1, '90d': 3.4 },
source: 'WatchCharts'
}
};
/**
* Generate realistic mock data for watches not in the predefined list
*/
function generateMockData(watchId) {
const basePrice = 3000 + Math.random() * 7000;
const priceVariation = basePrice * 0.2;
const retailMSRP = Math.round(basePrice * 1.2); // MSRP typically higher than resale
return {
reference: watchId.toUpperCase().slice(0, 15),
retailMSRP,
marketPrice: {
low: Math.round(basePrice - priceVariation),
high: Math.round(basePrice + priceVariation),
currency: 'USD'
},
recentSales: Array.from({ length: 5 }, (_, i) => {
const source = Math.random() > 0.5 ? 'Chrono24' : 'WatchCharts';
const price = Math.round(basePrice + (Math.random() - 0.5) * priceVariation);
return {
date: new Date(Date.now() - i * 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
price,
condition: ['Like New', 'Excellent', 'Very Good', 'Good'][Math.floor(Math.random() * 4)],
source,
href: source === 'Chrono24'
? `https://www.chrono24.com/omega/${watchId}--id${Math.floor(Math.random() * 10000000)}.htm`
: `https://watchcharts.com/listing/omega-${watchId}-${Math.floor(Math.random() * 100000)}`
};
}),
retailHistory: [
{ year: 2020, msrp: Math.round(retailMSRP * 0.85) },
{ year: 2021, msrp: Math.round(retailMSRP * 0.90) },
{ year: 2022, msrp: Math.round(retailMSRP * 0.93) },
{ year: 2023, msrp: Math.round(retailMSRP * 0.97) },
{ year: 2024, msrp: retailMSRP },
],
trends: {
'7d': Math.round((Math.random() - 0.3) * 10 * 10) / 10,
'30d': Math.round((Math.random() - 0.3) * 15 * 10) / 10,
'90d': Math.round((Math.random() - 0.3) * 20 * 10) / 10
},
source: Math.random() > 0.5 ? 'Chrono24' : 'WatchCharts'
};
}
/**
* Check if cached data is still valid
*/
function isCacheValid(cacheEntry) {
if (!cacheEntry) return false;
return Date.now() - cacheEntry.timestamp < CACHE_TTL;
}
/**
* Get data from cache or fetch new data
*/
function getCachedOrFetch(key, fetchFn) {
const cached = cache.get(key);
if (isCacheValid(cached)) {
return {
data: cached.data,
timestamp: cached.timestamp,
isStale: false,
source: cached.data.source || 'cache'
};
}
// If cache is expired but exists, we can return stale data on error
const staleData = cached?.data;
try {
const freshData = fetchFn();
const timestamp = Date.now();
cache.set(key, { data: freshData, timestamp });
return {
data: freshData,
timestamp,
isStale: false,
source: freshData.source || 'api'
};
} catch (error) {
console.error(`Error fetching data for ${key}:`, error);
// Return stale data if available
if (staleData) {
return {
data: staleData,
timestamp: cached.timestamp,
isStale: true,
source: 'cache (stale)'
};
}
throw error;
}
}
/**
* Normalize watch ID for matching
*/
function normalizeWatchId(watchId) {
return watchId?.toLowerCase().replace(/[^a-z0-9]/g, '-') || '';
}
/**
* Find matching mock data for a watch ID
*/
function findMockData(watchId) {
const normalized = normalizeWatchId(watchId);
// Direct match
if (mockMarketData[normalized]) {
return mockMarketData[normalized];
}
// Partial match
for (const [key, data] of Object.entries(mockMarketData)) {
if (normalized.includes(key) || key.includes(normalized)) {
return data;
}
}
// Generate mock data for unknown watches
return generateMockData(watchId);
}
/**
* Get current market price for a watch
* @param {string} watchId - The watch identifier
* @returns {Object} { data, timestamp, isStale, source }
*/
export function getMarketPrice(watchId) {
const cacheKey = `price-${watchId}`;
return getCachedOrFetch(cacheKey, () => {
const mockData = findMockData(watchId);
return {
low: mockData.marketPrice.low,
high: mockData.marketPrice.high,
currency: mockData.marketPrice.currency,
source: mockData.source
};
});
}
/**
* Get recent sales for a watch
* @param {string} watchId - The watch identifier
* @returns {Object} { data, timestamp, isStale, source }
*/
export function getRecentSales(watchId) {
const cacheKey = `sales-${watchId}`;
return getCachedOrFetch(cacheKey, () => {
const mockData = findMockData(watchId);
return {
sales: mockData.recentSales,
source: mockData.source
};
});
}
/**
* Get price trends for a watch
* @param {string} watchId - The watch identifier
* @returns {Object} { data, timestamp, isStale, source }
*/
export function getTrends(watchId) {
const cacheKey = `trends-${watchId}`;
return getCachedOrFetch(cacheKey, () => {
const mockData = findMockData(watchId);
return {
'7d': mockData.trends['7d'],
'30d': mockData.trends['30d'],
'90d': mockData.trends['90d'],
source: mockData.source
};
});
}
/**
* Get retail data for a watch
* @param {string} watchId - The watch identifier
* @returns {Object} { data, timestamp, isStale, source }
*/
export function getRetailData(watchId) {
const cacheKey = `retail-${watchId}`;
return getCachedOrFetch(cacheKey, () => {
const mockData = findMockData(watchId);
return {
reference: mockData.reference || watchId,
retailMSRP: mockData.retailMSRP || 5000,
retailHistory: mockData.retailHistory || [],
source: 'OMEGA Official'
};
});
}
/**
* Get all market data for a watch (combined call)
* @param {string} watchId - The watch identifier
* @returns {Object} Combined market data
*/
export function getAllMarketData(watchId) {
const price = getMarketPrice(watchId);
const sales = getRecentSales(watchId);
const trends = getTrends(watchId);
const retail = getRetailData(watchId);
return {
marketPrice: price.data,
recentSales: sales.data.sales,
trends: trends.data,
retailMSRP: retail.data.retailMSRP,
retailHistory: retail.data.retailHistory,
reference: retail.data.reference,
timestamp: Math.max(price.timestamp, sales.timestamp, trends.timestamp),
isStale: price.isStale || sales.isStale || trends.isStale,
source: price.data.source
};
}
/**
* Force refresh data for a watch (clears cache)
* @param {string} watchId - The watch identifier
*/
export function refreshMarketData(watchId) {
const normalized = normalizeWatchId(watchId);
// Clear all cached data for this watch
cache.delete(`price-${watchId}`);
cache.delete(`sales-${watchId}`);
cache.delete(`trends-${watchId}`);
cache.delete(`api-${watchId}`);
// Fetch fresh data
return getAllMarketData(watchId);
}
/**
* Async version that tries to get real data from backend API first
* @param {string} watchId - The watch identifier
* @returns {Promise<Object>} Market data with source info
*/
export async function getMarketDataAsync(watchId) {
const cacheKey = `api-${watchId}`;
const cached = cache.get(cacheKey);
// Return cached API data if valid
if (cached && isCacheValid(cached)) {
return {
...cached.data,
timestamp: cached.timestamp,
isStale: false,
cached: true
};
}
// Try to fetch from backend API
const apiData = await fetchFromBackend(watchId);
if (apiData && apiData.success && apiData.marketPrice) {
const result = {
marketPrice: apiData.marketPrice,
recentSales: apiData.priceHistory?.map(p => ({
date: p.date,
price: p.avgPrice,
condition: 'Market Average',
source: apiData.source
})) || [],
trends: {
'7d': apiData.priceHistory?.length > 0 ? 0 : null,
'30d': apiData.priceHistory?.length > 0 ? 0 : null,
'90d': apiData.priceHistory?.length > 0 ? 0 : null
},
source: apiData.source,
isMock: apiData.isMock || false
};
// Cache the API result
cache.set(cacheKey, { data: result, timestamp: Date.now() });
return {
...result,
timestamp: apiData.timestamp || Date.now(),
isStale: false,
cached: false
};
}
// Fallback to mock data
return {
...getAllMarketData(watchId),
isMock: true
};
}
/**
* Clear all cached data
*/
export function clearCache() {
cache.clear();
}
/**
* Get cache statistics
*/
export function getCacheStats() {
const stats = {
entries: cache.size,
staleEntries: 0,
freshEntries: 0
};
cache.forEach((entry) => {
if (isCacheValid(entry)) {
stats.freshEntries++;
} else {
stats.staleEntries++;
}
});
return stats;
}
export default {
getMarketPrice,
getRecentSales,
getTrends,
getAllMarketData,
getMarketDataAsync,
refreshMarketData,
clearCache,
getCacheStats
};