← back to Watches

services/price-aggregator.js

272 lines

/**
 * Price Aggregation Service
 * Combines prices from multiple sources with weighting and confidence scoring
 */

import waybackScraper from './wayback-scraper.js';
import githubDataset from './github-dataset.js';
import chrono24Scraper from './chrono24-scraper.js';
import pool from '../database/connection.js';

// Source weights (higher = more trusted)
const SOURCE_WEIGHTS = {
  official: 1.0,      // Official Omega prices
  chrono24: 0.9,      // Live marketplace
  github: 0.8,        // Curated datasets
  wayback: 0.7,       // Historical archives
  user: 0.5           // User-submitted
};

// Recency weights (exponential decay)
const RECENCY_HALF_LIFE_DAYS = 30;

/**
 * Calculate recency weight
 * @param {string} dateStr - Date string
 * @returns {number} Weight between 0 and 1
 */
function recencyWeight(dateStr) {
  const daysSince = (Date.now() - new Date(dateStr).getTime()) / (24 * 60 * 60 * 1000);
  return Math.pow(0.5, daysSince / RECENCY_HALF_LIFE_DAYS);
}

/**
 * Calculate weighted statistics
 * @param {array} prices - Array of { price, weight }
 * @returns {object} Statistics
 */
function weightedStatistics(prices) {
  if (prices.length === 0) {
    return { avg: 0, median: 0, min: 0, max: 0, stdDev: 0, confidence: 0 };
  }

  const totalWeight = prices.reduce((sum, p) => sum + p.weight, 0);
  const weightedSum = prices.reduce((sum, p) => sum + p.price * p.weight, 0);
  const weightedAvg = weightedSum / totalWeight;

  // Weighted standard deviation
  const weightedVariance = prices.reduce((sum, p) => {
    return sum + p.weight * Math.pow(p.price - weightedAvg, 2);
  }, 0) / totalWeight;
  const stdDev = Math.sqrt(weightedVariance);

  // Sort for median and range
  const sorted = [...prices].sort((a, b) => a.price - b.price);

  // Confidence based on data count and weight consistency
  const confidence = Math.min(
    5,
    Math.round(1 + Math.log2(prices.length + 1) + (totalWeight / prices.length))
  );

  return {
    avg: Math.round(weightedAvg),
    median: sorted[Math.floor(sorted.length / 2)].price,
    min: sorted[0].price,
    max: sorted[sorted.length - 1].price,
    stdDev: Math.round(stdDev),
    confidence,
    dataPoints: prices.length,
    totalWeight: Math.round(totalWeight * 100) / 100
  };
}

/**
 * Fetch prices from all sources for a watch reference
 * @param {string} reference - Watch reference number
 * @returns {array} Array of price records with weights
 */
export async function fetchAllPrices(reference) {
  const allPrices = [];

  // Parallel fetch from all sources
  const [waybackPrices, githubPrices, chrono24Prices] = await Promise.all([
    waybackScraper.scrapeHistoricalPrices(reference).catch(() => []),
    githubDataset.fetchOmegaPrices().then(prices =>
      prices.filter(p => p.reference?.includes(reference))
    ).catch(() => []),
    chrono24Scraper.scrapeOmegaListings(reference).catch(() => [])
  ]);

  // Process Wayback prices
  for (const p of waybackPrices) {
    allPrices.push({
      price: p.price,
      source: 'wayback',
      date: p.date,
      weight: SOURCE_WEIGHTS.wayback * recencyWeight(p.date),
      raw: p
    });
  }

  // Process GitHub prices
  for (const p of githubPrices) {
    allPrices.push({
      price: p.price,
      source: 'github',
      date: p.date || new Date().toISOString().slice(0, 10),
      weight: SOURCE_WEIGHTS.github * recencyWeight(p.date || new Date().toISOString()),
      raw: p
    });
  }

  // Process Chrono24 prices
  for (const p of chrono24Prices) {
    allPrices.push({
      price: p.price,
      source: 'chrono24',
      date: p.scrapedAt?.slice(0, 10) || new Date().toISOString().slice(0, 10),
      weight: SOURCE_WEIGHTS.chrono24 * recencyWeight(p.scrapedAt || new Date().toISOString()),
      condition: p.condition,
      raw: p
    });
  }

  return allPrices;
}

/**
 * Aggregate prices for a watch reference
 * @param {string} reference - Watch reference number
 * @returns {object} Aggregated price data
 */
export async function aggregatePrices(reference) {
  const allPrices = await fetchAllPrices(reference);

  if (allPrices.length === 0) {
    return {
      reference,
      hasData: false,
      message: 'No price data available'
    };
  }

  const stats = weightedStatistics(allPrices);

  // Group by source for breakdown
  const bySource = {};
  for (const p of allPrices) {
    if (!bySource[p.source]) {
      bySource[p.source] = [];
    }
    bySource[p.source].push(p.price);
  }

  const sourceBreakdown = {};
  for (const [source, prices] of Object.entries(bySource)) {
    sourceBreakdown[source] = {
      count: prices.length,
      avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
      min: Math.min(...prices),
      max: Math.max(...prices)
    };
  }

  return {
    reference,
    hasData: true,
    currentMarketPrice: stats.avg,
    priceRange: { min: stats.min, max: stats.max },
    median: stats.median,
    stdDev: stats.stdDev,
    confidence: stats.confidence,
    confidenceLabel: getConfidenceLabel(stats.confidence),
    dataPoints: stats.dataPoints,
    sourceBreakdown,
    lastUpdated: new Date().toISOString()
  };
}

/**
 * Get confidence label
 */
function getConfidenceLabel(confidence) {
  if (confidence >= 4) return 'high';
  if (confidence >= 2) return 'medium';
  return 'low';
}

/**
 * Store aggregated prices in database
 * @param {string} watchId - Watch UUID
 * @param {object} aggregation - Aggregated price data
 */
export async function storeAggregation(watchId, aggregation) {
  try {
    await pool.query(
      `INSERT INTO aggregated_prices (watch_id, avg_price, min_price, max_price, median_price,
                                       confidence, data_points, sources, updated_at)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
       ON CONFLICT (watch_id) DO UPDATE SET
         avg_price = $2, min_price = $3, max_price = $4, median_price = $5,
         confidence = $6, data_points = $7, sources = $8, updated_at = NOW()`,
      [
        watchId,
        aggregation.currentMarketPrice,
        aggregation.priceRange?.min,
        aggregation.priceRange?.max,
        aggregation.median,
        aggregation.confidence,
        aggregation.dataPoints,
        JSON.stringify(aggregation.sourceBreakdown)
      ]
    );
    return true;
  } catch (error) {
    console.error('Failed to store aggregation:', error.message);
    return false;
  }
}

/**
 * Get stored aggregation from database
 * @param {string} watchId - Watch UUID
 * @returns {object} Stored aggregation or null
 */
export async function getStoredAggregation(watchId) {
  try {
    const result = await pool.query(
      `SELECT * FROM aggregated_prices WHERE watch_id = $1`,
      [watchId]
    );
    return result.rows[0] || null;
  } catch (error) {
    console.error('Failed to get aggregation:', error.message);
    return null;
  }
}

/**
 * Aggregate prices for all watches
 * @param {array} watches - Array of watch records
 * @returns {array} Array of aggregation results
 */
export async function aggregateAllWatches(watches) {
  const results = [];

  for (const watch of watches) {
    try {
      console.log(`Aggregating prices for ${watch.reference}...`);
      const aggregation = await aggregatePrices(watch.reference);
      results.push({ watchId: watch.id, ...aggregation });

      // Rate limit between watches
      await new Promise(resolve => setTimeout(resolve, 1000));
    } catch (error) {
      console.error(`Failed to aggregate ${watch.reference}:`, error.message);
      results.push({ watchId: watch.id, error: error.message });
    }
  }

  return results;
}

export default {
  fetchAllPrices,
  aggregatePrices,
  storeAggregation,
  getStoredAggregation,
  aggregateAllWatches,
  SOURCE_WEIGHTS
};