/** * MarketMatchingAgent * Connects grower supply with consumer demand * * Responsibilities: * - Match supply commitments with demand signals * - Optimize delivery routes and logistics * - Facilitate fair pricing * - Track match success rates * - Enable local food distribution */ import { BaseAgent } from './BaseAgent'; import { AgentConfig, AgentTask } from './types'; import { getDemandForecaster } from '../demand/forecaster'; import { getTransportChain } from '../transport/tracker'; interface SupplyOffer { id: string; growerId: string; growerName: string; produceType: string; availableKg: number; pricePerKg: number; location: { latitude: number; longitude: number }; availableFrom: string; availableUntil: string; certifications: string[]; deliveryRadius: number; qualityGrade: 'premium' | 'standard' | 'economy'; } interface DemandRequest { id: string; consumerId: string; produceType: string; requestedKg: number; maxPricePerKg: number; location: { latitude: number; longitude: number }; neededBy: string; certificationRequirements: string[]; flexibleOnQuantity: boolean; flexibleOnTiming: boolean; } interface MarketMatch { id: string; supplyId: string; demandId: string; growerId: string; consumerId: string; produceType: string; matchedQuantityKg: number; agreedPricePerKg: number; deliveryDistanceKm: number; estimatedCarbonKg: number; matchScore: number; status: 'proposed' | 'accepted' | 'rejected' | 'fulfilled' | 'cancelled'; createdAt: string; deliveryDate?: string; matchFactors: { priceScore: number; distanceScore: number; certificationScore: number; timingScore: number; }; } interface MarketStats { totalMatches: number; successfulMatches: number; totalVolumeKg: number; totalRevenue: number; avgDistanceKm: number; avgCarbonSavedKg: number; matchSuccessRate: number; topProduceTypes: { type: string; volumeKg: number }[]; } interface PricingAnalysis { produceType: string; avgPrice: number; minPrice: number; maxPrice: number; priceRange: 'stable' | 'moderate' | 'volatile'; recommendedPrice: number; demandPressure: 'low' | 'medium' | 'high'; } export class MarketMatchingAgent extends BaseAgent { private supplyOffers: Map = new Map(); private demandRequests: Map = new Map(); private matches: Map = new Map(); private pricingData: Map = new Map(); private marketStats: MarketStats; constructor() { const config: AgentConfig = { id: 'market-matching-agent', name: 'Market Matching Agent', description: 'Connects supply with demand for local food distribution', enabled: true, intervalMs: 60000, // Run every minute priority: 'high', maxRetries: 3, timeoutMs: 30000 }; super(config); this.marketStats = { totalMatches: 0, successfulMatches: 0, totalVolumeKg: 0, totalRevenue: 0, avgDistanceKm: 0, avgCarbonSavedKg: 0, matchSuccessRate: 0, topProduceTypes: [] }; } /** * Main execution cycle */ async runOnce(): Promise { // Clean up expired offers and requests this.cleanupExpired(); // Find potential matches const newMatches = this.findMatches(); // Update pricing analysis this.updatePricingAnalysis(); // Update market statistics this.updateMarketStats(); // Generate alerts for unmatched supply/demand this.checkUnmatchedAlerts(); return this.createTaskResult('market_matching', 'completed', { activeSupplyOffers: this.supplyOffers.size, activeDemandRequests: this.demandRequests.size, newMatchesFound: newMatches.length, totalActiveMatches: this.matches.size, marketStats: this.marketStats }); } /** * Register a supply offer */ registerSupplyOffer(offer: SupplyOffer): void { this.supplyOffers.set(offer.id, offer); } /** * Register a demand request */ registerDemandRequest(request: DemandRequest): void { this.demandRequests.set(request.id, request); } /** * Find potential matches between supply and demand */ private findMatches(): MarketMatch[] { const newMatches: MarketMatch[] = []; for (const [supplyId, supply] of this.supplyOffers) { // Check if supply already has full matches const existingMatches = Array.from(this.matches.values()) .filter(m => m.supplyId === supplyId && m.status !== 'rejected' && m.status !== 'cancelled'); const matchedQuantity = existingMatches.reduce((sum, m) => sum + m.matchedQuantityKg, 0); const remainingSupply = supply.availableKg - matchedQuantity; if (remainingSupply <= 0) continue; // Find matching demand requests for (const [demandId, demand] of this.demandRequests) { // Check if demand already matched const demandMatches = Array.from(this.matches.values()) .filter(m => m.demandId === demandId && m.status !== 'rejected' && m.status !== 'cancelled'); if (demandMatches.length > 0) continue; // Check produce type match if (supply.produceType.toLowerCase() !== demand.produceType.toLowerCase()) continue; // Check price compatibility if (supply.pricePerKg > demand.maxPricePerKg) continue; // Check delivery radius const distance = this.calculateDistance(supply.location, demand.location); if (distance > supply.deliveryRadius) continue; // Check timing const supplyAvailable = new Date(supply.availableFrom); const demandNeeded = new Date(demand.neededBy); if (supplyAvailable > demandNeeded) continue; // Check certifications const certsMet = demand.certificationRequirements.every( cert => supply.certifications.includes(cert) ); if (!certsMet) continue; // Calculate match score const matchScore = this.calculateMatchScore(supply, demand, distance); // Calculate matched quantity const matchedQty = Math.min(remainingSupply, demand.requestedKg); // Estimate carbon footprint const carbonKg = this.estimateCarbonFootprint(distance, matchedQty); // Create match const match: MarketMatch = { id: `match-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, supplyId, demandId, growerId: supply.growerId, consumerId: demand.consumerId, produceType: supply.produceType, matchedQuantityKg: matchedQty, agreedPricePerKg: this.calculateFairPrice(supply.pricePerKg, demand.maxPricePerKg), deliveryDistanceKm: Math.round(distance * 100) / 100, estimatedCarbonKg: carbonKg, matchScore, status: 'proposed', createdAt: new Date().toISOString(), matchFactors: { priceScore: this.calculatePriceScore(supply.pricePerKg, demand.maxPricePerKg), distanceScore: this.calculateDistanceScore(distance), certificationScore: certsMet ? 100 : 0, timingScore: this.calculateTimingScore(supplyAvailable, demandNeeded) } }; this.matches.set(match.id, match); newMatches.push(match); // Alert for high-value matches if (matchScore >= 90 && matchedQty >= 10) { this.createAlert('info', 'High-Quality Match Found', `${matchedQty}kg of ${supply.produceType} matched between grower and consumer`, { relatedEntityId: match.id, relatedEntityType: 'match' } ); } } } return newMatches; } /** * Calculate match score (0-100) */ private calculateMatchScore(supply: SupplyOffer, demand: DemandRequest, distance: number): number { let score = 0; // Price score (30 points max) const priceRatio = supply.pricePerKg / demand.maxPricePerKg; score += Math.max(0, 30 * (1 - priceRatio)); // Distance score (25 points max) - shorter is better score += Math.max(0, 25 * (1 - distance / 100)); // Quantity match score (20 points max) const qtyRatio = Math.min(supply.availableKg, demand.requestedKg) / Math.max(supply.availableKg, demand.requestedKg); score += 20 * qtyRatio; // Quality score (15 points max) const qualityPoints: Record = { premium: 15, standard: 10, economy: 5 }; score += qualityPoints[supply.qualityGrade] || 5; // Certification match (10 points max) if (supply.certifications.length > 0) { const certMatch = demand.certificationRequirements.filter( cert => supply.certifications.includes(cert) ).length / Math.max(1, demand.certificationRequirements.length); score += 10 * certMatch; } else if (demand.certificationRequirements.length === 0) { score += 10; } return Math.round(Math.min(100, score)); } /** * Calculate fair price between supply and demand */ private calculateFairPrice(supplyPrice: number, maxDemandPrice: number): number { // Weighted average favoring supply price slightly return Math.round((supplyPrice * 0.6 + maxDemandPrice * 0.4) * 100) / 100; } /** * Calculate price score */ private calculatePriceScore(supplyPrice: number, maxDemandPrice: number): number { if (supplyPrice >= maxDemandPrice) return 0; return Math.round((1 - supplyPrice / maxDemandPrice) * 100); } /** * Calculate distance score */ private calculateDistanceScore(distance: number): number { // 100 points for 0km, 0 points for 100km+ return Math.max(0, Math.round(100 * (1 - distance / 100))); } /** * Calculate timing score */ private calculateTimingScore(available: Date, needed: Date): number { const daysUntilNeeded = (needed.getTime() - available.getTime()) / (24 * 60 * 60 * 1000); if (daysUntilNeeded < 0) return 0; if (daysUntilNeeded > 14) return 50; return Math.round(100 * (1 - daysUntilNeeded / 14)); } /** * Estimate carbon footprint for delivery */ private estimateCarbonFootprint(distanceKm: number, weightKg: number): number { // Assume local electric vehicle: 0.02 kg CO2 per km per kg return Math.round(0.02 * distanceKm * weightKg * 100) / 100; } /** * Calculate Haversine distance */ private calculateDistance( loc1: { latitude: number; longitude: number }, loc2: { latitude: number; longitude: number } ): number { const R = 6371; // km const dLat = (loc2.latitude - loc1.latitude) * Math.PI / 180; const dLon = (loc2.longitude - loc1.longitude) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(loc1.latitude * Math.PI / 180) * Math.cos(loc2.latitude * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * Clean up expired offers and requests */ private cleanupExpired(): void { const now = new Date(); for (const [id, supply] of this.supplyOffers) { if (new Date(supply.availableUntil) < now) { this.supplyOffers.delete(id); } } for (const [id, demand] of this.demandRequests) { if (new Date(demand.neededBy) < now) { this.demandRequests.delete(id); } } } /** * Update pricing analysis */ private updatePricingAnalysis(): void { const pricesByType = new Map(); for (const supply of this.supplyOffers.values()) { const prices = pricesByType.get(supply.produceType) || []; prices.push(supply.pricePerKg); pricesByType.set(supply.produceType, prices); } for (const [produceType, prices] of pricesByType) { if (prices.length === 0) continue; const avg = prices.reduce((a, b) => a + b, 0) / prices.length; const min = Math.min(...prices); const max = Math.max(...prices); const range = max - min; let priceRange: PricingAnalysis['priceRange']; if (range / avg < 0.1) priceRange = 'stable'; else if (range / avg < 0.3) priceRange = 'moderate'; else priceRange = 'volatile'; // Count demand for this produce const demandCount = Array.from(this.demandRequests.values()) .filter(d => d.produceType.toLowerCase() === produceType.toLowerCase()) .length; let demandPressure: PricingAnalysis['demandPressure']; if (demandCount > prices.length * 2) demandPressure = 'high'; else if (demandCount > prices.length) demandPressure = 'medium'; else demandPressure = 'low'; this.pricingData.set(produceType, { produceType, avgPrice: Math.round(avg * 100) / 100, minPrice: Math.round(min * 100) / 100, maxPrice: Math.round(max * 100) / 100, priceRange, recommendedPrice: Math.round((avg + (demandPressure === 'high' ? avg * 0.1 : 0)) * 100) / 100, demandPressure }); } } /** * Update market statistics */ private updateMarketStats(): void { const allMatches = Array.from(this.matches.values()); const successful = allMatches.filter(m => m.status === 'fulfilled'); const volumeByType = new Map(); let totalDistance = 0; let totalCarbon = 0; let totalRevenue = 0; for (const match of successful) { volumeByType.set( match.produceType, (volumeByType.get(match.produceType) || 0) + match.matchedQuantityKg ); totalDistance += match.deliveryDistanceKm; totalCarbon += match.estimatedCarbonKg; totalRevenue += match.matchedQuantityKg * match.agreedPricePerKg; } const topProduceTypes = Array.from(volumeByType.entries()) .map(([type, volumeKg]) => ({ type, volumeKg })) .sort((a, b) => b.volumeKg - a.volumeKg) .slice(0, 5); this.marketStats = { totalMatches: allMatches.length, successfulMatches: successful.length, totalVolumeKg: Math.round(successful.reduce((sum, m) => sum + m.matchedQuantityKg, 0) * 10) / 10, totalRevenue: Math.round(totalRevenue * 100) / 100, avgDistanceKm: successful.length > 0 ? Math.round(totalDistance / successful.length * 10) / 10 : 0, avgCarbonSavedKg: successful.length > 0 ? Math.round(totalCarbon / successful.length * 100) / 100 : 0, matchSuccessRate: allMatches.length > 0 ? Math.round(successful.length / allMatches.length * 100) : 0, topProduceTypes }; } /** * Check for unmatched supply/demand alerts */ private checkUnmatchedAlerts(): void { // Alert for supply that's been available for > 3 days without matches const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000; for (const supply of this.supplyOffers.values()) { const hasMatches = Array.from(this.matches.values()) .some(m => m.supplyId === supply.id); if (!hasMatches && new Date(supply.availableFrom).getTime() < threeDaysAgo) { this.createAlert('warning', 'Unmatched Supply', `${supply.availableKg}kg of ${supply.produceType} from ${supply.growerName} has no matches`, { actionRequired: 'Consider adjusting price or expanding delivery radius', relatedEntityId: supply.id, relatedEntityType: 'supply' } ); } } // Alert for urgent demand without matches const oneDayFromNow = Date.now() + 24 * 60 * 60 * 1000; for (const demand of this.demandRequests.values()) { const hasMatches = Array.from(this.matches.values()) .some(m => m.demandId === demand.id); if (!hasMatches && new Date(demand.neededBy).getTime() < oneDayFromNow) { this.createAlert('warning', 'Urgent Unmatched Demand', `${demand.requestedKg}kg of ${demand.produceType} needed within 24 hours has no matches`, { actionRequired: 'Expand search radius or consider alternatives', relatedEntityId: demand.id, relatedEntityType: 'demand' } ); } } } /** * Accept a match */ acceptMatch(matchId: string): boolean { const match = this.matches.get(matchId); if (!match || match.status !== 'proposed') return false; match.status = 'accepted'; match.deliveryDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(); return true; } /** * Fulfill a match */ fulfillMatch(matchId: string): boolean { const match = this.matches.get(matchId); if (!match || match.status !== 'accepted') return false; match.status = 'fulfilled'; this.marketStats.successfulMatches++; return true; } /** * Get match by ID */ getMatch(matchId: string): MarketMatch | null { return this.matches.get(matchId) || null; } /** * Get all matches */ getAllMatches(): MarketMatch[] { return Array.from(this.matches.values()); } /** * Get matches for a grower */ getGrowerMatches(growerId: string): MarketMatch[] { return Array.from(this.matches.values()) .filter(m => m.growerId === growerId); } /** * Get matches for a consumer */ getConsumerMatches(consumerId: string): MarketMatch[] { return Array.from(this.matches.values()) .filter(m => m.consumerId === consumerId); } /** * Get pricing analysis */ getPricingAnalysis(produceType?: string): PricingAnalysis[] { if (produceType) { const analysis = this.pricingData.get(produceType); return analysis ? [analysis] : []; } return Array.from(this.pricingData.values()); } /** * Get market stats */ getMarketStats(): MarketStats { return this.marketStats; } } // Singleton instance let marketAgentInstance: MarketMatchingAgent | null = null; export function getMarketMatchingAgent(): MarketMatchingAgent { if (!marketAgentInstance) { marketAgentInstance = new MarketMatchingAgent(); } return marketAgentInstance; }