← 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
};