diff --git a/contracts/matching/OrderMatchingEngine.ts b/contracts/matching/OrderMatchingEngine.ts new file mode 100644 index 0000000..823be0e --- /dev/null +++ b/contracts/matching/OrderMatchingEngine.ts @@ -0,0 +1,716 @@ +import { + IMatchingEngine, + Order, + OrderType, + OrderStatus, + EnergyQuality, + Location, + Match, + OrderBookEntry, + OrderBook as IOrderBook, + MatchingResult, + MatchingConfig, + MatchingPriority, + GeographicPreference, + QualityPreference, + MatchingStatistics, + OrderCreatedEvent, + OrderUpdatedEvent, + OrderCancelledEvent, + MatchExecutedEvent, + BatchMatchingCompletedEvent, + MATCHING_ERROR, + DEFAULT_MATCHING_CONFIG +} from './interfaces/IMatchingEngine'; +import { MatchingLib } from './libraries/MatchingLib'; +import { DoubleAuction } from './algorithms/DoubleAuction'; +import { OrderBook } from './structures/OrderBook'; + +/** + * @title OrderMatchingEngine + * @dev Intelligent on-chain order matching engine for energy trading + * @dev Implements continuous double auction with geographic and quality preferences + */ +export class OrderMatchingEngine implements IMatchingEngine { + + // State variables + private orderBook: OrderBook; + private config: MatchingConfig; + private admin: string; + private paused: boolean = false; + private emergencyMode: boolean = false; + + // Tracking variables + private orders: Map = new Map(); + private matches: Match[] = []; + private geographicPreferences: Map = new Map(); + private qualityPreferences: Map = new Map(); + private statistics: MatchingStatistics; + + // Events + private events: any[] = []; + + constructor(adminAddress: string) { + this.admin = adminAddress; + this.orderBook = new OrderBook(); + this.config = { ...DEFAULT_MATCHING_CONFIG }; + this.statistics = this.initializeStatistics(); + + this.emitEvent('EngineInitialized', { + admin: adminAddress, + timestamp: Date.now() + }); + } + + // --- Order Management --- + + public createOrder( + trader: string, + type: OrderType, + amount: number, + price: number, + location: Location, + quality: EnergyQuality, + expiresAt: number, + minFillAmount?: number, + maxPriceSlippage?: number, + preferredRegions?: string[], + qualityPreferences?: EnergyQuality[] + ): string { + this.whenNotPaused(); + this.validateTrader(trader); + + // Create order object + const orderId = MatchingLib.generateOrderId(trader, Date.now()); + const order: Order = { + id: orderId, + trader, + type, + amount, + price, + location, + quality, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt, + minFillAmount, + maxPriceSlippage, + preferredRegions, + qualityPreferences + }; + + // Validate order + const validation = MatchingLib.validateOrder(order); + if (!validation.isValid) { + throw new Error(`Invalid order: ${validation.errors.join(', ')}`); + } + + // Store order + this.orders.set(orderId, order); + this.orderBook.addOrder(order); + + // Update statistics + this.updateOrderStatistics(order, 'created'); + + // Emit event + this.emitEvent('OrderCreated', { + orderId, + trader, + type, + amount, + price, + location, + quality, + timestamp: Date.now() + } as OrderCreatedEvent); + + // Attempt immediate matching if enabled + if (this.config.enabled) { + this.attemptImmediateMatching(order); + } + + return orderId; + } + + public cancelOrder(orderId: string, caller: string): boolean { + this.whenNotPaused(); + + const order = this.orders.get(orderId); + if (!order) { + throw new Error(MATCHING_ERROR.ORDER_NOT_FOUND); + } + + if (order.trader !== caller && caller !== this.admin) { + throw new Error(MATCHING_ERROR.INSUFFICIENT_PERMISSIONS); + } + + if (order.status === OrderStatus.FILLED || order.status === OrderStatus.CANCELLED) { + throw new Error('Order cannot be cancelled'); + } + + // Update order status + order.status = OrderStatus.CANCELLED; + + // Remove from order book + this.orderBook.removeOrder(orderId); + + // Update statistics + this.updateOrderStatistics(order, 'cancelled'); + + // Calculate refund amount + const remainingAmount = MatchingLib.getRemainingAmount(order); + + // Emit event + this.emitEvent('OrderCancelled', { + orderId, + trader: order.trader, + reason: 'User cancellation', + refundedAmount: remainingAmount, + timestamp: Date.now() + } as OrderCancelledEvent); + + return true; + } + + public updateOrder( + orderId: string, + newAmount?: number, + newPrice?: number, + newExpiresAt?: number, + caller: string + ): boolean { + this.whenNotPaused(); + + const order = this.orders.get(orderId); + if (!order) { + throw new Error(MATCHING_ERROR.ORDER_NOT_FOUND); + } + + if (order.trader !== caller && caller !== this.admin) { + throw new Error(MATCHING_ERROR.INSUFFICIENT_PERMISSIONS); + } + + if (order.status !== OrderStatus.PENDING && order.status !== OrderStatus.PARTIALLY_FILLED) { + throw new Error('Order cannot be updated'); + } + + // Apply updates + const updates: Partial = {}; + if (newAmount !== undefined) updates.amount = newAmount; + if (newPrice !== undefined) updates.price = newPrice; + if (newExpiresAt !== undefined) updates.expiresAt = newExpiresAt; + + // Validate updated order + const updatedOrder = { ...order, ...updates }; + const validation = MatchingLib.validateOrder(updatedOrder); + if (!validation.isValid) { + throw new Error(`Invalid order update: ${validation.errors.join(', ')}`); + } + + // Update order + Object.assign(order, updates); + this.orderBook.updateOrder(orderId, updates); + + // Emit event + this.emitEvent('OrderUpdated', { + orderId, + status: order.status, + filledAmount: order.filledAmount, + remainingAmount: MatchingLib.getRemainingAmount(order), + timestamp: Date.now() + } as OrderUpdatedEvent); + + // Attempt matching after update + if (this.config.enabled) { + this.attemptImmediateMatching(order); + } + + return true; + } + + // --- Matching Functions --- + + public matchSingleOrder(orderId: string): Match[] { + this.whenNotPaused(); + this.whenMatchingEnabled(); + + const order = this.orders.get(orderId); + if (!order) { + throw new Error(MATCHING_ERROR.ORDER_NOT_FOUND); + } + + const oppositeOrders = Array.from(this.orders.values()) + .filter(o => o.type !== order.type); + + const matches = DoubleAuction.matchSingleOrder(order, oppositeOrders, this.config); + + // Process matches + for (const match of matches) { + this.processMatch(match); + } + + return matches; + } + + public matchBatch(orderIds: string[]): MatchingResult { + this.whenNotPaused(); + this.whenMatchingEnabled(); + + const batchOrders = orderIds + .map(id => this.orders.get(id)) + .filter(order => order !== undefined) as Order[]; + + const result = DoubleAuction.runBatchMatching(batchOrders, this.config); + + // Process matches + for (const match of result.matches) { + this.processMatch(match); + } + + // Update order book with remaining orders + this.updateOrderBookFromResult(result); + + // Emit batch completion event + this.emitEvent('BatchMatchingCompleted', { + batchId: `batch_${Date.now()}`, + matchesCount: result.matches.length, + totalAmount: result.totalMatchedAmount, + totalFees: result.totalMatchingFees, + processingTime: result.processingTime, + timestamp: Date.now() + } as BatchMatchingCompletedEvent); + + return result; + } + + public runContinuousMatching(): MatchingResult { + this.whenNotPaused(); + this.whenMatchingEnabled(); + + const buyOrders = Array.from(this.orders.values()) + .filter(o => o.type === OrderType.BUY); + const sellOrders = Array.from(this.orders.values()) + .filter(o => o.type === OrderType.SELL); + + const result = DoubleAuction.runContinuousMatching(buyOrders, sellOrders, this.config); + + // Process matches + for (const match of result.matches) { + this.processMatch(match); + } + + // Update order book + this.updateOrderBookFromResult(result); + + return result; + } + + // --- Order Book Management --- + + public getOrderBook(): IOrderBook { + return this.orderBook; + } + + public getOrdersByType(type: OrderType): Order[] { + return Array.from(this.orders.values()).filter(order => order.type === type); + } + + public getOrdersByTrader(trader: string): Order[] { + return Array.from(this.orders.values()).filter(order => order.trader === trader); + } + + public getOrdersByLocation(location: Location, radius: number): Order[] { + return this.orderBook.getOrdersByLocation(location, radius); + } + + public getOrdersByQuality(quality: EnergyQuality): Order[] { + return this.orderBook.getOrdersByQuality(quality); + } + + // --- Configuration Management --- + + public updateConfig(config: Partial, caller: string): boolean { + this.onlyAdmin(caller); + + // Validate configuration + const newConfig = { ...this.config, ...config }; + this.validateConfig(newConfig); + + this.config = newConfig; + + this.emitEvent('ConfigUpdated', { + config: newConfig, + updatedBy: caller, + timestamp: Date.now() + }); + + return true; + } + + public getConfig(): MatchingConfig { + return { ...this.config }; + } + + // --- Preference Management --- + + public setGeographicPreference( + trader: string, + preferences: GeographicPreference[], + caller: string + ): boolean { + this.validateTraderPermission(trader, caller); + + this.geographicPreferences.set(trader, preferences); + + this.emitEvent('GeographicPreferenceSet', { + trader, + preferences, + timestamp: Date.now() + }); + + return true; + } + + public setQualityPreference( + trader: string, + preferences: QualityPreference[], + caller: string + ): boolean { + this.validateTraderPermission(trader, caller); + + this.qualityPreferences.set(trader, preferences); + + this.emitEvent('QualityPreferenceSet', { + trader, + preferences, + timestamp: Date.now() + }); + + return true; + } + + // --- Statistics and Analytics --- + + public getStatistics(): MatchingStatistics { + return { ...this.statistics }; + } + + public getMatchingHistory(limit: number): Match[] { + return this.matches.slice(-limit); + } + + public getOrderHistory(trader: string, limit: number): Order[] { + return this.getOrdersByTrader(trader) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); + } + + // --- Fee Management --- + + public calculateMatchingFee(amount: number, price: number): number { + return MatchingLib.calculateMatchingFee(amount, price, this.config.matchingFeeRate); + } + + public distributeMatchingFees(fees: number): boolean { + // Simple fee distribution - in production, this would distribute to various stakeholders + this.statistics.totalMatchingFees += fees; + + this.emitEvent('FeesDistributed', { + amount: fees, + timestamp: Date.now() + }); + + return true; + } + + // --- Utility Functions --- + + public isValidOrder(order: Order): boolean { + const validation = MatchingLib.validateOrder(order); + return validation.isValid; + } + + public calculateDistance(loc1: Location, loc2: Location): number { + return MatchingLib.calculateDistance(loc1, loc2); + } + + public calculateQualityScore(requested: EnergyQuality, offered: EnergyQuality): number { + return MatchingLib.calculateQualityScore(requested, offered); + } + + public calculatePriceScore(bidPrice: number, askPrice: number): number { + return MatchingLib.calculatePriceScore(bidPrice, askPrice); + } + + // --- Admin Functions --- + + public pause(caller: string): boolean { + this.onlyAdmin(caller); + this.paused = true; + + this.emitEvent('EnginePaused', { by: caller, timestamp: Date.now() }); + return true; + } + + public unpause(caller: string): boolean { + this.onlyAdmin(caller); + this.paused = false; + + this.emitEvent('EngineUnpaused', { by: caller, timestamp: Date.now() }); + return true; + } + + public emergencyCancelAll(reason: string, caller: string): boolean { + this.onlyAdmin(caller); + + this.emergencyMode = true; + + // Cancel all pending orders + const pendingOrders = Array.from(this.orders.values()) + .filter(o => o.status === OrderStatus.PENDING || o.status === OrderStatus.PARTIALLY_FILLED); + + for (const order of pendingOrders) { + order.status = OrderStatus.CANCELLED; + this.orderBook.removeOrder(order.id); + + this.emitEvent('OrderCancelled', { + orderId: order.id, + trader: order.trader, + reason: `Emergency cancellation: ${reason}`, + refundedAmount: MatchingLib.getRemainingAmount(order), + timestamp: Date.now() + } as OrderCancelledEvent); + } + + this.emitEvent('EmergencyModeActivated', { + reason, + activatedBy: caller, + timestamp: Date.now() + }); + + return true; + } + + // --- Event Handlers --- + + public onOrderCreated?: (event: OrderCreatedEvent) => void; + public onOrderUpdated?: (event: OrderUpdatedEvent) => void; + public onOrderCancelled?: (event: OrderCancelledEvent) => void; + public onMatchExecuted?: (event: MatchExecutedEvent) => void; + public onBatchMatchingCompleted?: (event: BatchMatchingCompletedEvent) => void; + + // --- Private Helper Functions --- + + private whenNotPaused(): void { + if (this.paused) { + throw new Error('Matching engine is paused'); + } + } + + private whenMatchingEnabled(): void { + if (!this.config.enabled) { + throw new Error('Matching is disabled'); + } + } + + private onlyAdmin(caller: string): void { + if (caller !== this.admin) { + throw new Error(MATCHING_ERROR.INSUFFICIENT_PERMISSIONS); + } + } + + private validateTrader(trader: string): void { + if (!trader || trader.trim() === '') { + throw new Error('Invalid trader address'); + } + } + + private validateTraderPermission(trader: string, caller: string): void { + if (trader !== caller && caller !== this.admin) { + throw new Error(MATCHING_ERROR.INSUFFICIENT_PERMISSIONS); + } + } + + private validateConfig(config: MatchingConfig): void { + if (config.matchingFeeRate < 0 || config.matchingFeeRate > 1000) { + throw new Error('Invalid matching fee rate'); + } + + if (config.maxDistance < 0) { + throw new Error('Invalid max distance'); + } + + const totalWeight = config.priceWeight + config.distanceWeight + config.qualityWeight; + if (totalWeight !== 100) { + throw new Error('Weight distribution must sum to 100'); + } + } + + private attemptImmediateMatching(order: Order): void { + try { + const matches = this.matchSingleOrder(order.id); + if (matches.length > 0) { + this.emitEvent('ImmediateMatch', { + orderId: order.id, + matchesCount: matches.length, + timestamp: Date.now() + }); + } + } catch (error) { + // Log error but don't fail order creation + console.error('Immediate matching failed:', error); + } + } + + private processMatch(match: Match): void { + // Update order statuses + const buyOrder = this.orders.get(match.buyOrderId); + const sellOrder = this.orders.get(match.sellOrderId); + + if (buyOrder) { + buyOrder.filledAmount += match.amount; + if (MatchingLib.isOrderFullyFilled(buyOrder)) { + buyOrder.status = OrderStatus.FILLED; + } else { + buyOrder.status = OrderStatus.PARTIALLY_FILLED; + } + } + + if (sellOrder) { + sellOrder.filledAmount += match.amount; + if (MatchingLib.isOrderFullyFilled(sellOrder)) { + sellOrder.status = OrderStatus.FILLED; + } else { + sellOrder.status = OrderStatus.PARTIALLY_FILLED; + } + } + + // Store match + this.matches.push(match); + + // Update statistics + this.updateMatchStatistics(match); + + // Emit event + this.emitEvent('MatchExecuted', { + matchId: match.id, + buyOrderId: match.buyOrderId, + sellOrderId: match.sellOrderId, + amount: match.amount, + price: match.price, + buyer: buyOrder?.trader || '', + seller: sellOrder?.trader || '', + matchingFee: match.matchingFee, + timestamp: match.timestamp + } as MatchExecutedEvent); + } + + private updateOrderBookFromResult(result: MatchingResult): void { + // This would update the order book based on matching results + // In a real implementation, this would sync the order book state + this.orderBook.lastUpdated = Date.now(); + } + + private initializeStatistics(): MatchingStatistics { + return { + totalOrders: 0, + totalMatches: 0, + totalVolume: 0, + averageMatchSize: 0, + averageMatchingTime: 0, + fillRate: 0, + priceImprovement: 0, + geographicOptimization: 0, + qualityOptimization: 0, + gasUsagePerMatch: 0 + }; + } + + private updateOrderStatistics(order: Order, action: 'created' | 'cancelled'): void { + if (action === 'created') { + this.statistics.totalOrders++; + } + } + + private updateMatchStatistics(match: Match): void { + this.statistics.totalMatches++; + this.statistics.totalVolume += match.amount; + this.statistics.averageMatchSize = this.statistics.totalVolume / this.statistics.totalMatches; + + // Update optimization metrics + this.statistics.geographicOptimization = + (this.statistics.geographicOptimization + match.distanceScore) / 2; + this.statistics.qualityOptimization = + (this.statistics.qualityOptimization + match.qualityScore) / 2; + } + + private emitEvent(eventName: string, data: any): void { + const event = { + event: eventName, + timestamp: Date.now(), + data + }; + + this.events.push(event); + + // Call event handlers if they exist + switch (eventName) { + case 'OrderCreated': + this.onOrderCreated?.(data as OrderCreatedEvent); + break; + case 'OrderUpdated': + this.onOrderUpdated?.(data as OrderUpdatedEvent); + break; + case 'OrderCancelled': + this.onOrderCancelled?.(data as OrderCancelledEvent); + break; + case 'MatchExecuted': + this.onMatchExecuted?.(data as MatchExecutedEvent); + break; + case 'BatchMatchingCompleted': + this.onBatchMatchingCompleted?.(data as BatchMatchingCompletedEvent); + break; + } + } + + public getPastEvents(): any[] { + return [...this.events]; + } + + // --- Advanced Features --- + + public getMarketDepth(type: OrderType, levels: number = 10): { price: number; amount: number; count: number }[] { + return this.orderBook.getMarketDepth(type, levels); + } + + public getMarketStatistics(): { + bestBid: number | null; + bestAsk: number | null; + spread: number | null; + midPrice: number | null; + bidVolume: number; + askVolume: number; + totalVolume: number; + } { + const stats = this.orderBook.getStatistics(); + return { + bestBid: stats.bestBid, + bestAsk: stats.bestAsk, + spread: stats.spread, + midPrice: this.orderBook.getMidPrice(), + bidVolume: stats.bidVolume, + askVolume: stats.askVolume, + totalVolume: stats.totalVolume + }; + } + + public detectMarketManipulation(): { + isSuspicious: boolean; + reasons: string[]; + confidence: number; + } { + const allOrders = Array.from(this.orders.values()); + return DoubleAuction.detectMarketManipulation(allOrders); + } +} diff --git a/contracts/matching/algorithms/DoubleAuction.ts b/contracts/matching/algorithms/DoubleAuction.ts new file mode 100644 index 0000000..0625e75 --- /dev/null +++ b/contracts/matching/algorithms/DoubleAuction.ts @@ -0,0 +1,509 @@ +import { + Order, + OrderType, + OrderStatus, + EnergyQuality, + Location, + Match, + OrderBookEntry, + MatchingResult, + MatchingConfig, + MatchingPriority +} from '../interfaces/IMatchingEngine'; +import { MatchingLib } from '../libraries/MatchingLib'; + +/** + * @title DoubleAuction + * @dev Continuous double auction matching algorithm implementation + * @dev Implements price-time priority with geographic and quality preferences + */ +export class DoubleAuction { + + /** + * @dev Run continuous double auction matching + */ + static runContinuousMatching( + buyOrders: Order[], + sellOrders: Order[], + config: MatchingConfig + ): MatchingResult { + const startTime = Date.now(); + const matches: Match[] = []; + + // Filter and sort orders + const validBuyOrders = this.filterValidOrders(buyOrders); + const validSellOrders = this.filterValidOrders(sellOrders); + + const sortedBuys = MatchingLib.sortOrdersByPriceTime(validBuyOrders, OrderType.BUY); + const sortedSells = MatchingLib.sortOrdersByPriceTime(validSellOrders, OrderType.SELL); + + // Convert to order book entries for easier processing + const buyEntries = this.convertToOrderBookEntries(sortedBuys); + const sellEntries = this.convertToOrderBookEntries(sortedSells); + + // Perform matching + const { matches: newMatches, remainingBids, remainingAsks } = + this.performMatching(buyEntries, sellEntries, config); + + matches.push(...newMatches); + + // Calculate statistics + const totalMatchedAmount = matches.reduce((sum, match) => sum + match.amount, 0); + const totalMatchingFees = matches.reduce((sum, match) => sum + match.matchingFee, 0); + const averagePrice = matches.length > 0 + ? matches.reduce((sum, match) => sum + match.price, 0) / matches.length + : 0; + + const processingTime = Date.now() - startTime; + + return { + matches, + remainingBids, + remainingAsks, + totalMatchedAmount, + totalMatchingFees, + averagePrice, + processingTime + }; + } + + /** + * @dev Run batch matching for multiple orders + */ + static runBatchMatching( + orders: Order[], + config: MatchingConfig + ): MatchingResult { + // Separate buy and sell orders + const buyOrders = orders.filter(o => o.type === OrderType.BUY); + const sellOrders = orders.filter(o => o.type === OrderType.SELL); + + return this.runContinuousMatching(buyOrders, sellOrders, config); + } + + /** + * @dev Match a single order against the order book + */ + static matchSingleOrder( + order: Order, + oppositeOrders: Order[], + config: MatchingConfig + ): Match[] { + const matches: Match[] = []; + + if (!MatchingLib.isOrderExpired(order) && + order.status === OrderStatus.PENDING || order.status === OrderStatus.PARTIALLY_FILLED) { + + const validOppositeOrders = this.filterValidOrders(oppositeOrders); + const sortedOpposite = MatchingLib.sortOrdersByPriceTime( + validOppositeOrders, + order.type === OrderType.BUY ? OrderType.SELL : OrderType.BUY + ); + + const orderEntries = this.convertToOrderBookEntries([order]); + const oppositeEntries = this.convertToOrderBookEntries(sortedOpposite); + + const { matches: newMatches } = this.performMatching( + order.type === OrderType.BUY ? orderEntries : oppositeEntries, + order.type === OrderType.BUY ? oppositeEntries : orderEntries, + config + ); + + matches.push(...newMatches); + } + + return matches; + } + + /** + * @dev Perform the core matching algorithm + */ + private static performMatching( + bids: OrderBookEntry[], + asks: OrderBookEntry[], + config: MatchingConfig + ): { matches: Match[]; remainingBids: OrderBookEntry[]; remainingAsks: OrderBookEntry[] } { + const matches: Match[] = []; + const remainingBids = [...bids]; + const remainingAsks = [...asks]; + + let bidIndex = 0; + let askIndex = 0; + + while (bidIndex < remainingBids.length && askIndex < remainingAsks.length) { + const bid = remainingBids[bidIndex]; + const ask = remainingAsks[askIndex]; + + // Check if orders can match + if (bid.price < ask.price) { + // No more matches possible at current price levels + break; + } + + // Calculate compatibility scores + const compatibility = this.calculateCompatibility(bid, ask, config); + + if (compatibility.overallScore > 0) { + // Calculate match amount + const matchAmount = Math.min(bid.amount, ask.amount); + + // Create match + const match: Match = { + id: MatchingLib.generateMatchId(bid.orderId, ask.orderId, Date.now()), + buyOrderId: bid.orderId, + sellOrderId: ask.orderId, + amount: matchAmount, + price: ask.price, // Use ask price (maker price) + timestamp: Date.now(), + matchingFee: MatchingLib.calculateMatchingFee(matchAmount, ask.price, config.matchingFeeRate), + qualityScore: compatibility.qualityScore, + distanceScore: compatibility.distanceScore, + priceScore: compatibility.priceScore + }; + + matches.push(match); + + // Update remaining amounts + bid.amount -= matchAmount; + ask.amount -= matchAmount; + + // Remove fully filled orders + if (bid.amount <= 0) { + bidIndex++; + } + + if (ask.amount <= 0) { + askIndex++; + } + } else { + // Orders are not compatible, move to next order + if (config.priority === MatchingPriority.PRICE_TIME) { + // In price-time priority, move the less competitive order + askIndex++; + } else { + // In other priorities, try next combination + askIndex++; + } + } + } + + return { + matches, + remainingBids: remainingBids.slice(bidIndex).filter(entry => entry.amount > 0), + remainingAsks: remainingAsks.slice(askIndex).filter(entry => entry.amount > 0) + }; + } + + /** + * @dev Calculate compatibility score between bid and ask + */ + private static calculateCompatibility( + bid: OrderBookEntry, + ask: OrderBookEntry, + config: MatchingConfig + ): { overallScore: number; qualityScore: number; distanceScore: number; priceScore: number } { + // Quality compatibility + const qualityScore = MatchingLib.calculateQualityScore( + bid.quality, + ask.quality + ); + + // Geographic compatibility + const distance = MatchingLib.calculateDistance(bid.location, ask.location); + const distanceScore = MatchingLib.calculateDistanceScore(distance, config.maxDistance); + + // Price compatibility + const priceScore = MatchingLib.calculatePriceScore(bid.price, ask.price); + + // Overall score + const overallScore = MatchingLib.calculateOverallScore( + priceScore, + distanceScore, + qualityScore, + config + ); + + return { + overallScore, + qualityScore, + distanceScore, + priceScore + }; + } + + /** + * @dev Filter out invalid or expired orders + */ + private static filterValidOrders(orders: Order[]): Order[] { + return orders.filter(order => + !MatchingLib.isOrderExpired(order) && + (order.status === OrderStatus.PENDING || order.status === OrderStatus.PARTIALLY_FILLED) && + MatchingLib.getRemainingAmount(order) > 0 + ); + } + + /** + * @dev Convert orders to order book entries + */ + private static convertToOrderBookEntries(orders: Order[]): OrderBookEntry[] { + return orders.map(order => ({ + orderId: order.id, + price: order.price, + amount: MatchingLib.getRemainingAmount(order), + timestamp: order.createdAt, + trader: order.trader, + location: order.location, + quality: order.quality + })); + } + + /** + * @dev Calculate market depth for price levels + */ + static calculateMarketDepth( + orders: Order[], + type: OrderType, + priceLevels: number = 10 + ): { price: number; amount: number; count: number }[] { + const validOrders = this.filterValidOrders(orders); + const sortedOrders = MatchingLib.sortOrdersByPriceTime(validOrders, type); + + const depth: { price: number; amount: number; count: number }[] = []; + const priceMap = new Map(); + + // Aggregate by price levels + for (const order of sortedOrders) { + const remaining = MatchingLib.getRemainingAmount(order); + const current = priceMap.get(order.price) || { amount: 0, count: 0 }; + + priceMap.set(order.price, { + amount: current.amount + remaining, + count: current.count + 1 + }); + } + + // Convert to array and sort + const sortedDepths = Array.from(priceMap.entries()) + .map(([price, data]) => ({ price, ...data })) + .sort((a, b) => { + if (type === OrderType.BUY) { + return b.price - a.price; // Descending for bids + } else { + return a.price - b.price; // Ascending for asks + } + }) + .slice(0, priceLevels); + + return sortedDepths; + } + + /** + * @dev Calculate spread and market statistics + */ + static calculateMarketStatistics( + buyOrders: Order[], + sellOrders: Order[] + ): { + bestBid: number | null; + bestAsk: number | null; + spread: number | null; + midPrice: number | null; + bidVolume: number; + askVolume: number; + totalVolume: number; + } { + const validBuys = this.filterValidOrders(buyOrders); + const validSells = this.filterValidOrders(sellOrders); + + const sortedBuys = MatchingLib.sortOrdersByPriceTime(validBuys, OrderType.BUY); + const sortedSells = MatchingLib.sortOrdersByPriceTime(validSells, OrderType.SELL); + + const bestBid = sortedBuys.length > 0 ? sortedBuys[0].price : null; + const bestAsk = sortedSells.length > 0 ? sortedSells[0].price : null; + + const spread = bestBid && bestAsk ? bestAsk - bestBid : null; + const midPrice = bestBid && bestAsk ? (bestBid + bestAsk) / 2 : null; + + const bidVolume = sortedBuys.reduce((sum, order) => + sum + MatchingLib.getRemainingAmount(order), 0); + const askVolume = sortedSells.reduce((sum, order) => + sum + MatchingLib.getRemainingAmount(order), 0); + const totalVolume = bidVolume + askVolume; + + return { + bestBid, + bestAsk, + spread, + midPrice, + bidVolume, + askVolume, + totalVolume + }; + } + + /** + * @dev Simulate matching for price impact analysis + */ + static simulatePriceImpact( + order: Order, + orderBook: Order[], + config: MatchingConfig + ): { + estimatedPrice: number; + priceImpact: number; + fillProbability: number; + estimatedSlippage: number; + } { + const oppositeOrders = orderBook.filter(o => o.type !== order.type); + const validOpposite = this.filterValidOrders(oppositeOrders); + const sortedOpposite = MatchingLib.sortOrdersByPriceTime( + validOpposite, + order.type === OrderType.BUY ? OrderType.SELL : OrderType.BUY + ); + + let totalFillable = 0; + let weightedPrice = 0; + let totalWeight = 0; + + for (const oppOrder of sortedOpposite) { + const remaining = MatchingLib.getRemainingAmount(oppOrder); + const canFill = Math.min(remaining, order.amount - totalFillable); + + if (canFill > 0) { + totalFillable += canFill; + weightedPrice += oppOrder.price * canFill; + totalWeight += canFill; + + if (totalFillable >= order.amount) break; + } + } + + const estimatedPrice = totalWeight > 0 ? weightedPrice / totalWeight : order.price; + const priceImpact = order.type === OrderType.BUY + ? ((estimatedPrice - order.price) / order.price) * 100 + : ((order.price - estimatedPrice) / order.price) * 100; + + const fillProbability = totalWeight > 0 ? Math.min(100, (totalWeight / order.amount) * 100) : 0; + const estimatedSlippage = Math.abs(estimatedPrice - order.price); + + return { + estimatedPrice, + priceImpact, + fillProbability, + estimatedSlippage + }; + } + + /** + * @dev Optimize order placement strategy + */ + static optimizeOrderPlacement( + desiredAmount: number, + orderBook: Order[], + config: MatchingConfig + ): { + recommendedPrice: number; + splitOrders: { amount: number; price: number }[]; + expectedFillTime: number; + } { + const marketStats = this.calculateMarketStatistics( + orderBook.filter(o => o.type === OrderType.BUY), + orderBook.filter(o => o.type === OrderType.SELL) + ); + + const recommendedPrice = marketStats.midPrice || 0; + + // Simple order splitting strategy + const maxOrderSize = config.maxOrderAmount / 10; // Split into 10ths + const splitOrders: { amount: number; price: number }[] = []; + + let remainingAmount = desiredAmount; + while (remainingAmount > 0) { + const orderSize = Math.min(remainingAmount, maxOrderSize); + splitOrders.push({ + amount: orderSize, + price: recommendedPrice + }); + remainingAmount -= orderSize; + } + + // Estimate fill time based on market activity + const expectedFillTime = Math.max(1, Math.ceil(desiredAmount / (marketStats.totalVolume / 24))); // Hours + + return { + recommendedPrice, + splitOrders, + expectedFillTime + }; + } + + /** + * @dev Detect and prevent market manipulation + */ + static detectMarketManipulation( + orders: Order[], + timeWindow: number = 3600000 // 1 hour + ): { + isSuspicious: boolean; + reasons: string[]; + confidence: number; + } { + const now = Date.now(); + const recentOrders = orders.filter(o => now - o.createdAt <= timeWindow); + + const reasons: string[] = []; + let confidence = 0; + + // Check for unusually large orders + const avgOrderSize = recentOrders.reduce((sum, o) => sum + o.amount, 0) / recentOrders.length; + const largeOrders = recentOrders.filter(o => o.amount > avgOrderSize * 10); + + if (largeOrders.length > 0) { + reasons.push('Unusually large orders detected'); + confidence += 30; + } + + // Check for rapid order placement/cancellation + const cancellations = recentOrders.filter(o => o.status === OrderStatus.CANCELLED); + if (cancellations.length > recentOrders.length * 0.5) { + reasons.push('High order cancellation rate'); + confidence += 25; + } + + // Check for spoofing (large orders with immediate cancellation) + const spoofingOrders = recentOrders.filter(o => + o.amount > avgOrderSize * 5 && + o.status === OrderStatus.CANCELLED && + (now - o.createdAt) < 60000 // Cancelled within 1 minute + ); + + if (spoofingOrders.length > 0) { + reasons.push('Potential spoofing activity'); + confidence += 35; + } + + // Check for wash trading (self-trading) + const traderGroups = new Map(); + recentOrders.forEach(o => { + const count = traderGroups.get(o.trader) || 0; + traderGroups.set(o.trader, count + 1); + }); + + const activeTraders = Array.from(traderGroups.values()); + const avgOrdersPerTrader = activeTraders.reduce((sum, count) => sum + count, 0) / activeTraders.length; + + const dominantTraders = Array.from(traderGroups.entries()) + .filter(([_, count]) => count > avgOrdersPerTrader * 5); + + if (dominantTraders.length > 0) { + reasons.push('Concentrated trading activity'); + confidence += 20; + } + + return { + isSuspicious: confidence > 50, + reasons, + confidence: Math.min(100, confidence) + }; + } +} diff --git a/contracts/matching/interfaces/IMatchingEngine.ts b/contracts/matching/interfaces/IMatchingEngine.ts new file mode 100644 index 0000000..3c36e7e --- /dev/null +++ b/contracts/matching/interfaces/IMatchingEngine.ts @@ -0,0 +1,321 @@ +/** + * @title IMatchingEngine + * @dev Interface for the intelligent on-chain order matching engine + * @dev Handles energy buy and sell orders with advanced matching algorithms + */ + +export enum OrderType { + BUY = 'BUY', + SELL = 'SELL' +} + +export enum OrderStatus { + PENDING = 'PENDING', + PARTIALLY_FILLED = 'PARTIALLY_FILLED', + FILLED = 'FILLED', + CANCELLED = 'CANCELLED', + EXPIRED = 'EXPIRED' +} + +export enum EnergyQuality { + STANDARD = 'STANDARD', + PREMIUM = 'PREMIUM', + GREEN = 'GREEN', + PREMIUM_GREEN = 'PREMIUM_GREEN' +} + +export enum MatchingPriority { + PRICE_TIME = 'PRICE_TIME', + GEOGRAPHIC = 'GEOGRAPHIC', + QUALITY = 'QUALITY', + HYBRID = 'HYBRID' +} + +export interface Location { + latitude: number; + longitude: number; + region: string; + country: string; +} + +export interface Order { + id: string; + trader: string; + type: OrderType; + amount: number; + price: number; + location: Location; + quality: EnergyQuality; + status: OrderStatus; + filledAmount: number; + createdAt: number; + expiresAt: number; + minFillAmount?: number; + maxPriceSlippage?: number; + preferredRegions?: string[]; + qualityPreferences?: EnergyQuality[]; +} + +export interface Match { + id: string; + buyOrderId: string; + sellOrderId: string; + amount: number; + price: number; + timestamp: number; + matchingFee: number; + qualityScore: number; + distanceScore: number; + priceScore: number; +} + +export interface OrderBookEntry { + orderId: string; + price: number; + amount: number; + timestamp: number; + trader: string; + location: Location; + quality: EnergyQuality; +} + +export interface OrderBook { + bids: OrderBookEntry[]; // Buy orders sorted by price descending + asks: OrderBookEntry[]; // Sell orders sorted by price ascending + lastUpdated: number; + totalVolume: number; + spread: number; +} + +export interface MatchingResult { + matches: Match[]; + remainingBids: OrderBookEntry[]; + remainingAsks: OrderBookEntry[]; + totalMatchedAmount: number; + totalMatchingFees: number; + averagePrice: number; + processingTime: number; +} + +export interface MatchingConfig { + enabled: boolean; + priority: MatchingPriority; + maxDistance: number; // in kilometers + qualityWeight: number; // 0-100 + distanceWeight: number; // 0-100 + priceWeight: number; // 0-100 + matchingFeeRate: number; // basis points + minOrderAmount: number; + maxOrderAmount: number; + batchProcessingSize: number; + priceTolerance: number; // percentage +} + +export interface GeographicPreference { + region: string; + priority: number; // 1-10 + maxDistance: number; +} + +export interface QualityPreference { + quality: EnergyQuality; + minScore: number; // 0-100 + premium: number; // percentage premium willing to pay +} + +export interface MatchingStatistics { + totalOrders: number; + totalMatches: number; + totalVolume: number; + averageMatchSize: number; + averageMatchingTime: number; + fillRate: number; + priceImprovement: number; + geographicOptimization: number; + qualityOptimization: number; + gasUsagePerMatch: number; +} + +// Events +export interface OrderCreatedEvent { + orderId: string; + trader: string; + type: OrderType; + amount: number; + price: number; + location: Location; + quality: EnergyQuality; + timestamp: number; +} + +export interface OrderUpdatedEvent { + orderId: string; + status: OrderStatus; + filledAmount: number; + remainingAmount: number; + timestamp: number; +} + +export interface OrderCancelledEvent { + orderId: string; + trader: string; + reason: string; + refundedAmount: number; + timestamp: number; +} + +export interface MatchExecutedEvent { + matchId: string; + buyOrderId: string; + sellOrderId: string; + amount: number; + price: number; + buyer: string; + seller: string; + matchingFee: number; + timestamp: number; +} + +export interface BatchMatchingCompletedEvent { + batchId: string; + matchesCount: number; + totalAmount: number; + totalFees: number; + processingTime: number; + timestamp: number; +} + +// Main interface +export interface IMatchingEngine { + // Order management + createOrder( + trader: string, + type: OrderType, + amount: number, + price: number, + location: Location, + quality: EnergyQuality, + expiresAt: number, + minFillAmount?: number, + maxPriceSlippage?: number, + preferredRegions?: string[], + qualityPreferences?: EnergyQuality[] + ): string; + + cancelOrder(orderId: string, caller: string): boolean; + + updateOrder( + orderId: string, + newAmount?: number, + newPrice?: number, + newExpiresAt?: number, + caller: string + ): boolean; + + // Matching functions + matchSingleOrder(orderId: string): Match[]; + + matchBatch(orderIds: string[]): MatchingResult; + + runContinuousMatching(): MatchingResult; + + // Order book management + getOrderBook(): OrderBook; + + getOrdersByType(type: OrderType): Order[]; + + getOrdersByTrader(trader: string): Order[]; + + getOrdersByLocation(location: Location, radius: number): Order[]; + + getOrdersByQuality(quality: EnergyQuality): Order[]; + + // Matching configuration + updateConfig(config: Partial, caller: string): boolean; + + getConfig(): MatchingConfig; + + // Geographic and quality preferences + setGeographicPreference( + trader: string, + preferences: GeographicPreference[], + caller: string + ): boolean; + + setQualityPreference( + trader: string, + preferences: QualityPreference[], + caller: string + ): boolean; + + // Statistics and analytics + getStatistics(): MatchingStatistics; + + getMatchingHistory(limit: number): Match[]; + + getOrderHistory(trader: string, limit: number): Order[]; + + // Fee management + calculateMatchingFee(amount: number, price: number): number; + + distributeMatchingFees(fees: number): boolean; + + // Utility functions + isValidOrder(order: Order): boolean; + + calculateDistance(loc1: Location, loc2: Location): number; + + calculateQualityScore(requested: EnergyQuality, offered: EnergyQuality): number; + + calculatePriceScore(bidPrice: number, askPrice: number): number; + + // Admin functions + pause(caller: string): boolean; + + unpause(caller: string): boolean; + + emergencyCancelAll(reason: string, caller: string): boolean; + + // Events + onOrderCreated?: (event: OrderCreatedEvent) => void; + onOrderUpdated?: (event: OrderUpdatedEvent) => void; + onOrderCancelled?: (event: OrderCancelledEvent) => void; + onMatchExecuted?: (event: MatchExecutedEvent) => void; + onBatchMatchingCompleted?: (event: BatchMatchingCompletedEvent) => void; +} + +export const MATCHING_ERROR = { + ORDER_NOT_FOUND: 'ORDER_NOT_FOUND', + ORDER_ALREADY_FILLED: 'ORDER_ALREADY_FILLED', + ORDER_EXPIRED: 'ORDER_EXPIRED', + INSUFFICIENT_AMOUNT: 'INSUFFICIENT_AMOUNT', + INVALID_PRICE: 'INVALID_PRICE', + INVALID_LOCATION: 'INVALID_LOCATION', + INVALID_QUALITY: 'INVALID_QUALITY', + NO_MATCHES_FOUND: 'NO_MATCHES_FOUND', + MATCHING_PAUSED: 'MATCHING_PAUSED', + INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS', + INVALID_CONFIGURATION: 'INVALID_CONFIGURATION', + GAS_LIMIT_EXCEEDED: 'GAS_LIMIT_EXCEEDED' +} as const; + +export const DEFAULT_MATCHING_CONFIG: MatchingConfig = { + enabled: true, + priority: MatchingPriority.HYBRID, + maxDistance: 500, // 500 km + qualityWeight: 30, + distanceWeight: 30, + priceWeight: 40, + matchingFeeRate: 10, // 0.1% + minOrderAmount: 1, + maxOrderAmount: 1000000, + batchProcessingSize: 100, + priceTolerance: 5 // 5% +}; + +export const LOCATION_PRESETS = { + EUROPE: { latitude: 50.0, longitude: 10.0, region: 'EU', country: 'DE' }, + NORTH_AMERICA: { latitude: 40.0, longitude: -100.0, region: 'NA', country: 'US' }, + ASIA_PACIFIC: { latitude: 0.0, longitude: 120.0, region: 'APAC', country: 'SG' }, + GLOBAL: { latitude: 0.0, longitude: 0.0, region: 'GLOBAL', country: 'GLOBAL' } +} as const; diff --git a/contracts/matching/libraries/MatchingLib.ts b/contracts/matching/libraries/MatchingLib.ts new file mode 100644 index 0000000..5de73f0 --- /dev/null +++ b/contracts/matching/libraries/MatchingLib.ts @@ -0,0 +1,483 @@ +import { + Order, + OrderType, + OrderStatus, + EnergyQuality, + Location, + Match, + OrderBookEntry, + MatchingConfig, + GeographicPreference, + QualityPreference, + MatchingPriority +} from '../interfaces/IMatchingEngine'; + +/** + * @title MatchingLib + * @dev Library containing core matching logic and utility functions + */ +export class MatchingLib { + + /** + * @dev Calculate distance between two locations using Haversine formula + */ + static calculateDistance(loc1: Location, loc2: Location): number { + const R = 6371; // Earth's radius in kilometers + const dLat = this.toRadians(loc2.latitude - loc1.latitude); + const dLon = this.toRadians(loc2.longitude - loc1.longitude); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRadians(loc1.latitude)) * Math.cos(this.toRadians(loc2.latitude)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + /** + * @dev Convert degrees to radians + */ + private static toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * @dev Calculate quality compatibility score between requested and offered quality + */ + static calculateQualityScore(requested: EnergyQuality, offered: EnergyQuality): number { + const qualityHierarchy = { + [EnergyQuality.STANDARD]: 1, + [EnergyQuality.PREMIUM]: 2, + [EnergyQuality.GREEN]: 3, + [EnergyQuality.PREMIUM_GREEN]: 4 + }; + + const requestedLevel = qualityHierarchy[requested]; + const offeredLevel = qualityHierarchy[offered]; + + if (offeredLevel >= requestedLevel) { + // Perfect match or better quality offered + return 100; + } else { + // Lower quality offered - calculate penalty + const qualityGap = requestedLevel - offeredLevel; + return Math.max(0, 100 - (qualityGap * 25)); + } + } + + /** + * @dev Calculate price score based on how favorable the price is + */ + static calculatePriceScore(bidPrice: number, askPrice: number): number { + if (bidPrice >= askPrice) { + // Price is acceptable, calculate score based on spread + const spread = bidPrice - askPrice; + const spreadPercentage = (spread / askPrice) * 100; + + // Higher score for tighter spreads + return Math.max(0, 100 - spreadPercentage); + } else { + // Price doesn't match + return 0; + } + } + + /** + * @dev Calculate distance score based on geographic proximity + */ + static calculateDistanceScore(distance: number, maxDistance: number): number { + if (distance <= maxDistance) { + // Linear decay from 100 to 0 based on distance + return Math.max(0, 100 - (distance / maxDistance * 100)); + } else { + return 0; + } + } + + /** + * @dev Calculate overall matching score using weighted approach + */ + static calculateOverallScore( + priceScore: number, + distanceScore: number, + qualityScore: number, + config: MatchingConfig + ): number { + const totalWeight = config.priceWeight + config.distanceWeight + config.qualityWeight; + + return ( + (priceScore * config.priceWeight / totalWeight) + + (distanceScore * config.distanceWeight / totalWeight) + + (qualityScore * config.qualityWeight / totalWeight) + ); + } + + /** + * @dev Check if two orders are compatible for matching + */ + static areOrdersCompatible( + buyOrder: Order, + sellOrder: Order, + config: MatchingConfig + ): boolean { + // Basic compatibility checks + if (buyOrder.type !== OrderType.BUY || sellOrder.type !== OrderType.SELL) { + return false; + } + + if (buyOrder.status !== OrderStatus.PENDING && buyOrder.status !== OrderStatus.PARTIALLY_FILLED) { + return false; + } + + if (sellOrder.status !== OrderStatus.PENDING && sellOrder.status !== OrderStatus.PARTIALLY_FILLED) { + return false; + } + + // Price compatibility + if (buyOrder.price < sellOrder.price) { + return false; + } + + // Quality compatibility + const qualityScore = this.calculateQualityScore(buyOrder.quality, sellOrder.quality); + if (qualityScore === 0) { + return false; + } + + // Geographic compatibility + const distance = this.calculateDistance(buyOrder.location, sellOrder.location); + if (distance > config.maxDistance) { + return false; + } + + // Amount compatibility + const buyRemaining = buyOrder.amount - buyOrder.filledAmount; + const sellRemaining = sellOrder.amount - sellOrder.filledAmount; + + if (buyRemaining < (buyOrder.minFillAmount || 1) || sellRemaining < (buyOrder.minFillAmount || 1)) { + return false; + } + + return true; + } + + /** + * @dev Calculate optimal match amount between two orders + */ + static calculateMatchAmount(buyOrder: Order, sellOrder: Order): number { + const buyRemaining = buyOrder.amount - buyOrder.filledAmount; + const sellRemaining = sellOrder.amount - sellOrder.filledAmount; + + // Match the minimum of remaining amounts + let matchAmount = Math.min(buyRemaining, sellRemaining); + + // Respect minimum fill amount + const minFillAmount = buyOrder.minFillAmount || 1; + if (matchAmount < minFillAmount) { + return 0; + } + + return matchAmount; + } + + /** + * @dev Calculate execution price for a match + */ + static calculateExecutionPrice(buyOrder: Order, sellOrder: Order): number { + // Use the sell order price (maker price) for execution + // This follows the typical maker-taker model + return sellOrder.price; + } + + /** + * @dev Calculate matching fee for a transaction + */ + static calculateMatchingFee(amount: number, price: number, feeRate: number): number { + const totalValue = amount * price; + return (totalValue * feeRate) / 10000; // feeRate is in basis points + } + + /** + * @dev Sort orders by price-time priority + */ + static sortOrdersByPriceTime(orders: Order[], type: OrderType): Order[] { + return orders.sort((a, b) => { + if (type === OrderType.BUY) { + // Buy orders: higher price first, then earlier time + if (b.price !== a.price) { + return b.price - a.price; + } + return a.createdAt - b.createdAt; + } else { + // Sell orders: lower price first, then earlier time + if (b.price !== a.price) { + return a.price - b.price; + } + return a.createdAt - b.createdAt; + } + }); + } + + /** + * @dev Sort orders by geographic proximity to a reference location + */ + static sortOrdersByGeography(orders: Order[], referenceLocation: Location): Order[] { + return orders.sort((a, b) => { + const distanceA = this.calculateDistance(referenceLocation, a.location); + const distanceB = this.calculateDistance(referenceLocation, b.location); + return distanceA - distanceB; + }); + } + + /** + * @dev Sort orders by quality preference + */ + static sortOrdersByQuality(orders: Order[], preferredQuality: EnergyQuality): Order[] { + return orders.sort((a, b) => { + const scoreA = this.calculateQualityScore(preferredQuality, a.quality); + const scoreB = this.calculateQualityScore(preferredQuality, b.quality); + return scoreB - scoreA; // Higher score first + }); + } + + /** + * @dev Apply hybrid sorting based on configuration + */ + static sortOrdersHybrid( + orders: Order[], + type: OrderType, + config: MatchingConfig, + referenceLocation?: Location, + preferredQuality?: EnergyQuality + ): Order[] { + // Create a copy to avoid mutating the original + const sortedOrders = [...orders]; + + // Calculate composite scores for each order + const scoredOrders = sortedOrders.map(order => { + let score = 0; + let totalWeight = 0; + + // Price score + if (config.priceWeight > 0) { + let priceScore = 0; + if (type === OrderType.BUY) { + // For buy orders, higher price is better + priceScore = Math.min(100, (order.price / 1000) * 100); // Normalize to 0-100 + } else { + // For sell orders, lower price is better + priceScore = Math.max(0, 100 - (order.price / 1000) * 100); + } + score += priceScore * config.priceWeight; + totalWeight += config.priceWeight; + } + + // Geographic score + if (config.distanceWeight > 0 && referenceLocation) { + const distance = this.calculateDistance(referenceLocation, order.location); + const distanceScore = this.calculateDistanceScore(distance, config.maxDistance); + score += distanceScore * config.distanceWeight; + totalWeight += config.distanceWeight; + } + + // Quality score + if (config.qualityWeight > 0 && preferredQuality) { + const qualityScore = this.calculateQualityScore(preferredQuality, order.quality); + score += qualityScore * config.qualityWeight; + totalWeight += config.qualityWeight; + } + + // Time bonus (earlier orders get slight bonus) + const timeBonus = Math.max(0, 100 - (Date.now() - order.createdAt) / (1000 * 60 * 60 * 24)); // Decay over days + score += timeBonus * 10; // Small weight for time + totalWeight += 10; + + return { + order, + score: totalWeight > 0 ? score / totalWeight : 0 + }; + }); + + // Sort by composite score (descending) + scoredOrders.sort((a, b) => b.score - a.score); + + return scoredOrders.map(item => item.order); + } + + /** + * @dev Validate order parameters + */ + static validateOrder(order: Partial): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!order.trader || order.trader.trim() === '') { + errors.push('Invalid trader address'); + } + + if (!order.type || !Object.values(OrderType).includes(order.type)) { + errors.push('Invalid order type'); + } + + if (!order.amount || order.amount <= 0) { + errors.push('Invalid amount'); + } + + if (!order.price || order.price <= 0) { + errors.push('Invalid price'); + } + + if (!order.location || !this.isValidLocation(order.location)) { + errors.push('Invalid location'); + } + + if (!order.quality || !Object.values(EnergyQuality).includes(order.quality)) { + errors.push('Invalid energy quality'); + } + + if (!order.expiresAt || order.expiresAt <= Date.now()) { + errors.push('Invalid expiration time'); + } + + if (order.minFillAmount && order.minFillAmount > order.amount) { + errors.push('Minimum fill amount exceeds order amount'); + } + + if (order.maxPriceSlippage && (order.maxPriceSlippage < 0 || order.maxPriceSlippage > 100)) { + errors.push('Invalid price slippage percentage'); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * @dev Validate location coordinates + */ + private static isValidLocation(location: Location): boolean { + return ( + location && + typeof location.latitude === 'number' && + typeof location.longitude === 'number' && + location.latitude >= -90 && + location.latitude <= 90 && + location.longitude >= -180 && + location.longitude <= 180 && + location.region && + location.country + ); + } + + /** + * @dev Check if order has expired + */ + static isOrderExpired(order: Order): boolean { + return Date.now() > order.expiresAt; + } + + /** + * @dev Check if order is fully filled + */ + static isOrderFullyFilled(order: Order): boolean { + return order.filledAmount >= order.amount; + } + + /** + * @dev Get remaining amount for an order + */ + static getRemainingAmount(order: Order): number { + return Math.max(0, order.amount - order.filledAmount); + } + + /** + * @dev Calculate fill percentage for an order + */ + static calculateFillPercentage(order: Order): number { + return (order.filledAmount / order.amount) * 100; + } + + /** + * @dev Generate unique order ID + */ + static generateOrderId(trader: string, timestamp: number): string { + return `${trader}_${timestamp}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * @dev Generate unique match ID + */ + static generateMatchId(buyOrderId: string, sellOrderId: string, timestamp: number): string { + return `match_${buyOrderId}_${sellOrderId}_${timestamp}`; + } + + /** + * @dev Optimize order matching for gas efficiency + */ + static optimizeMatchingBatch(orders: Order[], maxBatchSize: number): Order[][] { + const batches: Order[][] = []; + + // Separate buy and sell orders + const buyOrders = orders.filter(o => o.type === OrderType.BUY); + const sellOrders = orders.filter(o => o.type === OrderType.SELL); + + // Sort by price-time priority + const sortedBuys = this.sortOrdersByPriceTime(buyOrders, OrderType.BUY); + const sortedSells = this.sortOrdersByPriceTime(sellOrders, OrderType.SELL); + + // Create batches of optimal size + for (let i = 0; i < sortedBuys.length; i += maxBatchSize) { + const buyBatch = sortedBuys.slice(i, i + maxBatchSize); + const matchingSells = this.findBestMatchingSells(buyBatch, sortedSells); + + if (matchingSells.length > 0) { + batches.push([...buyBatch, ...matchingSells]); + } + } + + return batches; + } + + /** + * @dev Find best matching sell orders for a batch of buy orders + */ + private static findBestMatchingSells(buyOrders: Order[], sellOrders: Order[]): Order[] { + const matchingSells: Order[] = []; + const usedSellOrders = new Set(); + + for (const buyOrder of buyOrders) { + for (const sellOrder of sellOrders) { + if (usedSellOrders.has(sellOrder.id)) continue; + + if (buyOrder.price >= sellOrder.price) { + matchingSells.push(sellOrder); + usedSellOrders.add(sellOrder.id); + break; + } + } + } + + return matchingSells; + } + + /** + * @dev Calculate gas estimate for matching operation + */ + static estimateGasForMatching(orderCount: number): number { + // Base gas cost + per-order cost + const baseGas = 21000; + const perOrderGas = 15000; + const matchingLogicGas = 25000; + + return baseGas + (orderCount * perOrderGas) + matchingLogicGas; + } + + /** + * @dev Check if matching operation is within gas limits + */ + static isWithinGasLimits(orderCount: number, gasLimit: number): boolean { + const estimatedGas = this.estimateGasForMatching(orderCount); + return estimatedGas <= gasLimit; + } +} diff --git a/contracts/matching/structures/OrderBook.ts b/contracts/matching/structures/OrderBook.ts new file mode 100644 index 0000000..51e8d4a --- /dev/null +++ b/contracts/matching/structures/OrderBook.ts @@ -0,0 +1,575 @@ +import { + Order, + OrderType, + OrderStatus, + EnergyQuality, + Location, + OrderBookEntry, + OrderBook as IOrderBook, + MatchingConfig, + GeographicPreference, + QualityPreference +} from '../interfaces/IMatchingEngine'; +import { MatchingLib } from '../libraries/MatchingLib'; + +/** + * @title OrderBook + * @dev Data structure for managing buy and sell orders + * @dev Provides efficient order insertion, removal, and querying + */ +export class OrderBook implements IOrderBook { + public bids: OrderBookEntry[] = []; + public asks: OrderBookEntry[] = []; + public lastUpdated: number = Date.now(); + public totalVolume: number = 0; + public spread: number = 0; + + private orders: Map = new Map(); + private priceLevels: Map = new Map(); + private geographicIndex: Map> = new Map(); + private qualityIndex: Map> = new Map(); + + constructor() { + this.updateSpread(); + } + + /** + * @dev Add a new order to the order book + */ + addOrder(order: Order): boolean { + if (!this.isValidOrderToAdd(order)) { + return false; + } + + // Store the full order + this.orders.set(order.id, order); + + // Create order book entry + const entry: OrderBookEntry = { + orderId: order.id, + price: order.price, + amount: MatchingLib.getRemainingAmount(order), + timestamp: order.createdAt, + trader: order.trader, + location: order.location, + quality: order.quality + }; + + // Add to appropriate side + if (order.type === OrderType.BUY) { + this.insertBid(entry); + } else { + this.insertAsk(entry); + } + + // Update indexes + this.updateIndexes(order); + + // Update statistics + this.updateStatistics(); + + return true; + } + + /** + * @dev Remove an order from the order book + */ + removeOrder(orderId: string): boolean { + const order = this.orders.get(orderId); + if (!order) { + return false; + } + + // Remove from appropriate side + if (order.type === OrderType.BUY) { + this.removeBid(orderId); + } else { + this.removeAsk(orderId); + } + + // Remove from storage + this.orders.delete(orderId); + + // Remove from indexes + this.removeFromIndexes(order); + + // Update statistics + this.updateStatistics(); + + return true; + } + + /** + * @dev Update an existing order + */ + updateOrder(orderId: string, updates: Partial): boolean { + const order = this.orders.get(orderId); + if (!order) { + return false; + } + + // Remove old order + this.removeOrder(orderId); + + // Apply updates + const updatedOrder = { ...order, ...updates }; + + // Add updated order + return this.addOrder(updatedOrder); + } + + /** + * @dev Get order by ID + */ + getOrder(orderId: string): Order | undefined { + return this.orders.get(orderId); + } + + /** + * @dev Get all orders + */ + getAllOrders(): Order[] { + return Array.from(this.orders.values()); + } + + /** + * @dev Get orders by type + */ + getOrdersByType(type: OrderType): Order[] { + return Array.from(this.orders.values()).filter(order => order.type === type); + } + + /** + * @dev Get orders by trader + */ + getOrdersByTrader(trader: string): Order[] { + return Array.from(this.orders.values()).filter(order => order.trader === trader); + } + + /** + * @dev Get orders by location within radius + */ + getOrdersByLocation(location: Location, radius: number): Order[] { + const results: Order[] = []; + + for (const order of this.orders.values()) { + const distance = MatchingLib.calculateDistance(location, order.location); + if (distance <= radius) { + results.push(order); + } + } + + return results; + } + + /** + * @dev Get orders by quality + */ + getOrdersByQuality(quality: EnergyQuality): Order[] { + const orderIds = this.qualityIndex.get(quality) || new Set(); + return Array.from(orderIds) + .map(id => this.orders.get(id)) + .filter(order => order !== undefined) as Order[]; + } + + /** + * @dev Get best bid (highest buy order) + */ + getBestBid(): OrderBookEntry | null { + return this.bids.length > 0 ? this.bids[0] : null; + } + + /** + * @dev Get best ask (lowest sell order) + */ + getBestAsk(): OrderBookEntry | null { + return this.asks.length > 0 ? this.asks[0] : null; + } + + /** + * @dev Get market depth at different price levels + */ + getMarketDepth(type: OrderType, levels: number = 10): { price: number; amount: number; count: number }[] { + const entries = type === OrderType.BUY ? this.bids : this.asks; + const depth: { price: number; amount: number; count: number }[] = []; + + const priceMap = new Map(); + + for (const entry of entries) { + const current = priceMap.get(entry.price) || { amount: 0, count: 0 }; + priceMap.set(entry.price, { + amount: current.amount + entry.amount, + count: current.count + 1 + }); + } + + return Array.from(priceMap.entries()) + .map(([price, data]) => ({ price, ...data })) + .slice(0, levels); + } + + /** + * @dev Get spread between best bid and ask + */ + getSpread(): number { + return this.spread; + } + + /** + * @dev Get mid price + */ + getMidPrice(): number | null { + const bestBid = this.getBestBid(); + const bestAsk = this.getBestAsk(); + + if (bestBid && bestAsk) { + return (bestBid.price + bestAsk.price) / 2; + } + + return null; + } + + /** + * @dev Get total volume + */ + getTotalVolume(): number { + return this.totalVolume; + } + + /** + * @dev Get order book statistics + */ + getStatistics(): { + totalOrders: number; + buyOrders: number; + sellOrders: number; + totalVolume: number; + bidVolume: number; + askVolume: number; + spread: number; + bestBid: number | null; + bestAsk: number | null; + averagePrice: number; + priceRange: { min: number; max: number }; + } { + const buyOrders = this.bids.length; + const sellOrders = this.asks.length; + const totalOrders = buyOrders + sellOrders; + + const bidVolume = this.bids.reduce((sum, bid) => sum + bid.amount, 0); + const askVolume = this.asks.reduce((sum, ask) => sum + ask.amount, 0); + + const allPrices = [...this.bids.map(b => b.price), ...this.asks.map(a => a.price)]; + const averagePrice = allPrices.length > 0 + ? allPrices.reduce((sum, price) => sum + price, 0) / allPrices.length + : 0; + + const minPrice = allPrices.length > 0 ? Math.min(...allPrices) : 0; + const maxPrice = allPrices.length > 0 ? Math.max(...allPrices) : 0; + + return { + totalOrders, + buyOrders, + sellOrders, + totalVolume: this.totalVolume, + bidVolume, + askVolume, + spread: this.spread, + bestBid: this.getBestBid()?.price || null, + bestAsk: this.getBestAsk()?.price || null, + averagePrice, + priceRange: { min: minPrice, max: maxPrice } + }; + } + + /** + * @dev Find matching orders for a given order + */ + findMatchingOrders( + order: Order, + config: MatchingConfig, + maxMatches: number = 10 + ): OrderBookEntry[] { + const oppositeSide = order.type === OrderType.BUY ? this.asks : this.bids; + const matches: OrderBookEntry[] = []; + + for (const entry of oppositeSide) { + if (matches.length >= maxMatches) break; + + // Basic price check + if (order.type === OrderType.BUY && order.price < entry.price) continue; + if (order.type === OrderType.SELL && order.price > entry.price) continue; + + // Geographic check + const distance = MatchingLib.calculateDistance(order.location, entry.location); + if (distance > config.maxDistance) continue; + + // Quality check + const qualityScore = MatchingLib.calculateQualityScore(order.quality, entry.quality); + if (qualityScore === 0) continue; + + matches.push(entry); + } + + return matches; + } + + /** + * @dev Get order book snapshot for a specific time + */ + getSnapshot(timestamp: number): IOrderBook { + // Filter orders that existed at the given timestamp + const historicalOrders = Array.from(this.orders.values()) + .filter(order => order.createdAt <= timestamp); + + const snapshot = new OrderBook(); + + for (const order of historicalOrders) { + snapshot.addOrder(order); + } + + return snapshot; + } + + /** + * @dev Clear all orders + */ + clear(): void { + this.bids = []; + this.asks = []; + this.orders.clear(); + this.priceLevels.clear(); + this.geographicIndex.clear(); + this.qualityIndex.clear(); + this.totalVolume = 0; + this.spread = 0; + this.lastUpdated = Date.now(); + } + + /** + * @dev Validate order before adding + */ + private isValidOrderToAdd(order: Order): boolean { + if (this.orders.has(order.id)) { + return false; // Order already exists + } + + if (MatchingLib.isOrderExpired(order)) { + return false; // Order expired + } + + if (MatchingLib.isOrderFullyFilled(order)) { + return false; // Order already filled + } + + const validation = MatchingLib.validateOrder(order); + return validation.isValid; + } + + /** + * @dev Insert bid maintaining sorted order + */ + private insertBid(entry: OrderBookEntry): void { + // Find insertion point (highest price first, then earliest time) + let insertIndex = this.bids.length; + + for (let i = 0; i < this.bids.length; i++) { + if (entry.price > this.bids[i].price || + (entry.price === this.bids[i].price && entry.timestamp < this.bids[i].timestamp)) { + insertIndex = i; + break; + } + } + + this.bids.splice(insertIndex, 0, entry); + + // Update price level index + const priceKey = `bid_${entry.price}`; + const priceLevel = this.priceLevels.get(priceKey) || []; + priceLevel.push(entry); + this.priceLevels.set(priceKey, priceLevel); + } + + /** + * @dev Insert ask maintaining sorted order + */ + private insertAsk(entry: OrderBookEntry): void { + // Find insertion point (lowest price first, then earliest time) + let insertIndex = this.asks.length; + + for (let i = 0; i < this.asks.length; i++) { + if (entry.price < this.asks[i].price || + (entry.price === this.asks[i].price && entry.timestamp < this.asks[i].timestamp)) { + insertIndex = i; + break; + } + } + + this.asks.splice(insertIndex, 0, entry); + + // Update price level index + const priceKey = `ask_${entry.price}`; + const priceLevel = this.priceLevels.get(priceKey) || []; + priceLevel.push(entry); + this.priceLevels.set(priceKey, priceLevel); + } + + /** + * @dev Remove bid from order book + */ + private removeBid(orderId: string): void { + const index = this.bids.findIndex(entry => entry.orderId === orderId); + if (index !== -1) { + const entry = this.bids[index]; + this.bids.splice(index, 1); + + // Remove from price level index + const priceKey = `bid_${entry.price}`; + const priceLevel = this.priceLevels.get(priceKey) || []; + const priceIndex = priceLevel.findIndex(e => e.orderId === orderId); + if (priceIndex !== -1) { + priceLevel.splice(priceIndex, 1); + if (priceLevel.length === 0) { + this.priceLevels.delete(priceKey); + } + } + } + } + + /** + * @dev Remove ask from order book + */ + private removeAsk(orderId: string): void { + const index = this.asks.findIndex(entry => entry.orderId === orderId); + if (index !== -1) { + const entry = this.asks[index]; + this.asks.splice(index, 1); + + // Remove from price level index + const priceKey = `ask_${entry.price}`; + const priceLevel = this.priceLevels.get(priceKey) || []; + const priceIndex = priceLevel.findIndex(e => e.orderId === orderId); + if (priceIndex !== -1) { + priceLevel.splice(priceIndex, 1); + if (priceLevel.length === 0) { + this.priceLevels.delete(priceKey); + } + } + } + } + + /** + * @dev Update indexes for efficient querying + */ + private updateIndexes(order: Order): void { + // Geographic index + const geoKey = `${order.location.region}_${order.location.country}`; + const geoOrders = this.geographicIndex.get(geoKey) || new Set(); + geoOrders.add(order.id); + this.geographicIndex.set(geoKey, geoOrders); + + // Quality index + const qualityOrders = this.qualityIndex.get(order.quality) || new Set(); + qualityOrders.add(order.id); + this.qualityIndex.set(order.quality, qualityOrders); + } + + /** + * @dev Remove order from indexes + */ + private removeFromIndexes(order: Order): void { + // Remove from geographic index + const geoKey = `${order.location.region}_${order.location.country}`; + const geoOrders = this.geographicIndex.get(geoKey); + if (geoOrders) { + geoOrders.delete(order.id); + if (geoOrders.size === 0) { + this.geographicIndex.delete(geoKey); + } + } + + // Remove from quality index + const qualityOrders = this.qualityIndex.get(order.quality); + if (qualityOrders) { + qualityOrders.delete(order.id); + if (qualityOrders.size === 0) { + this.qualityIndex.delete(order.quality); + } + } + } + + /** + * @dev Update order book statistics + */ + private updateStatistics(): void { + this.lastUpdated = Date.now(); + + // Update total volume + this.totalVolume = this.bids.reduce((sum, bid) => sum + bid.amount, 0) + + this.asks.reduce((sum, ask) => sum + ask.amount, 0); + + // Update spread + this.updateSpread(); + } + + /** + * @dev Update spread calculation + */ + private updateSpread(): void { + const bestBid = this.getBestBid(); + const bestAsk = this.getBestAsk(); + + if (bestBid && bestAsk) { + this.spread = bestAsk.price - bestBid.price; + } else { + this.spread = 0; + } + } + + /** + * @dev Export order book data + */ + export(): { + bids: OrderBookEntry[]; + asks: OrderBookEntry[]; + lastUpdated: number; + totalVolume: number; + spread: number; + orders: Order[]; + } { + return { + bids: [...this.bids], + asks: [...this.asks], + lastUpdated: this.lastUpdated, + totalVolume: this.totalVolume, + spread: this.spread, + orders: this.getAllOrders() + }; + } + + /** + * @dev Import order book data + */ + import(data: { + bids: OrderBookEntry[]; + asks: OrderBookEntry[]; + lastUpdated: number; + totalVolume: number; + spread: number; + orders: Order[]; + }): void { + this.clear(); + + this.bids = data.bids; + this.asks = data.asks; + this.lastUpdated = data.lastUpdated; + this.totalVolume = data.totalVolume; + this.spread = data.spread; + + // Rebuild indexes + for (const order of data.orders) { + this.orders.set(order.id, order); + this.updateIndexes(order); + } + } +} diff --git a/docs/matching/OrderMatchingEngine.md b/docs/matching/OrderMatchingEngine.md new file mode 100644 index 0000000..75ae9ea --- /dev/null +++ b/docs/matching/OrderMatchingEngine.md @@ -0,0 +1,523 @@ +# Order Matching Engine Documentation + +## Overview + +The Order Matching Engine is an intelligent on-chain system designed to facilitate efficient energy trading by automatically matching buy and sell orders based on multiple criteria including price, quantity, geographic location, and energy quality preferences. + +## Architecture + +### Core Components + +1. **IMatchingEngine Interface** - Defines the contract interface and data structures +2. **OrderMatchingEngine** - Main contract implementing the matching logic +3. **MatchingLib** - Library containing core matching algorithms and utility functions +4. **DoubleAuction** - Continuous double auction matching algorithm implementation +5. **OrderBook** - Data structure for managing orders and market depth + +### Key Features + +- **Continuous Double Auction**: Real-time order matching with price-time priority +- **Geographic Matching**: Prioritizes local energy trades based on distance +- **Quality-Based Matching**: Matches orders based on energy quality preferences +- **Partial Order Fulfillment**: Handles quantity mismatches efficiently +- **Batch Processing**: Optimized for high-frequency trading (1000+ orders per block) +- **Gas Optimization**: Efficient algorithms to minimize transaction costs +- **Market Manipulation Detection**: Built-in safeguards against unfair trading practices + +## Installation and Deployment + +### Prerequisites + +- Node.js >= 16.0.0 +- Hardhat framework +- TypeScript + +### Deployment + +1. **Deploy the matching engine**: +```bash +npm run deploy deploy +``` + +2. **Verify on Etherscan**: +```bash +npm run deploy verify +``` + +3. **Run performance tests**: +```bash +npm run deploy test +``` + +### Configuration + +The matching engine can be configured with the following parameters: + +```typescript +interface MatchingConfig { + enabled: boolean; // Enable/disable matching + priority: MatchingPriority; // Matching priority strategy + maxDistance: number; // Maximum distance for geographic matching (km) + qualityWeight: number; // Weight for quality scoring (0-100) + distanceWeight: number; // Weight for distance scoring (0-100) + priceWeight: number; // Weight for price scoring (0-100) + matchingFeeRate: number; // Matching fee rate in basis points + minOrderAmount: number; // Minimum order amount + maxOrderAmount: number; // Maximum order amount + batchProcessingSize: number; // Batch size for processing + priceTolerance: number; // Price tolerance percentage +} +``` + +## Usage Guide + +### Creating Orders + +#### Buy Order +```typescript +const buyOrderId = await matchingEngine.createOrder( + traderAddress, // Trader's wallet address + OrderType.BUY, // Order type + 1000, // Amount of energy (kWh) + ethers.utils.parseUnits('50', 18), // Price per unit + { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }, // Location + EnergyQuality.STANDARD, // Energy quality + Math.floor(Date.now() / 1000) + 3600, // Expiration time + 100, // Minimum fill amount + 5, // Maximum price slippage (%) + ['Berlin', 'Hamburg'], // Preferred regions + [EnergyQuality.STANDARD, EnergyQuality.GREEN] // Quality preferences +); +``` + +#### Sell Order +```typescript +const sellOrderId = await matchingEngine.createOrder( + traderAddress, + OrderType.SELL, + 1000, + ethers.utils.parseUnits('48', 18), + location, + EnergyQuality.PREMIUM, + expirationTime, + 100, + 5, + ['Berlin', 'Munich'], + [EnergyQuality.PREMIUM, EnergyQuality.PREMIUM_GREEN] +); +``` + +### Order Management + +#### Cancel Order +```typescript +await matchingEngine.cancelOrder(orderId, traderAddress); +``` + +#### Update Order +```typescript +await matchingEngine.updateOrder( + orderId, + undefined, // New amount (optional) + ethers.utils.parseUnits('52', 18), // New price (optional) + newExpirationTime, // New expiration (optional) + traderAddress +); +``` + +### Matching Operations + +#### Single Order Matching +```typescript +const matches = await matchingEngine.matchSingleOrder(orderId); +``` + +#### Batch Matching +```typescript +const result = await matchingEngine.matchBatch([orderId1, orderId2, orderId3]); +``` + +#### Continuous Matching +```typescript +const result = await matchingEngine.runContinuousMatching(); +``` + +### Setting Preferences + +#### Geographic Preferences +```typescript +await matchingEngine.setGeographicPreference( + traderAddress, + [ + { + region: 'Berlin', + priority: 10, + maxDistance: 100 + }, + { + region: 'Hamburg', + priority: 8, + maxDistance: 300 + } + ], + traderAddress +); +``` + +#### Quality Preferences +```typescript +await matchingEngine.setQualityPreference( + traderAddress, + [ + { + quality: EnergyQuality.GREEN, + minScore: 80, + premium: 10 + }, + { + quality: EnergyQuality.PREMIUM_GREEN, + minScore: 90, + premium: 15 + } + ], + traderAddress +); +``` + +## Matching Algorithm + +### Double Auction Mechanism + +The matching engine uses a continuous double auction algorithm with the following priority: + +1. **Price-Time Priority**: Higher bids and lower asks are matched first +2. **Geographic Proximity**: Closer locations get priority based on configuration +3. **Quality Preferences**: Higher quality energy gets preference +4. **Time Priority**: Earlier orders get preference when other factors are equal + +### Scoring System + +Each potential match is scored based on: + +- **Price Score**: Based on spread between bid and ask prices +- **Distance Score**: Based on geographic proximity +- **Quality Score**: Based on energy quality compatibility +- **Overall Score**: Weighted combination of all factors + +### Matching Process + +1. **Order Validation**: Verify order parameters and expiration +2. **Compatibility Check**: Ensure orders can match based on basic criteria +3. **Score Calculation**: Calculate compatibility scores for all potential matches +4. **Ranking**: Rank matches by overall score +5. **Execution**: Execute matches in order of priority +6. **Settlement**: Update order statuses and process fees + +## Data Structures + +### Order Structure + +```typescript +interface Order { + id: string; // Unique order identifier + trader: string; // Trader's wallet address + type: OrderType; // BUY or SELL + amount: number; // Order amount + price: number; // Price per unit + location: Location; // Geographic location + quality: EnergyQuality; // Energy quality + status: OrderStatus; // Current order status + filledAmount: number; // Amount already filled + createdAt: number; // Creation timestamp + expiresAt: number; // Expiration timestamp + minFillAmount?: number; // Minimum fill amount + maxPriceSlippage?: number; // Maximum price slippage percentage + preferredRegions?: string[]; // Preferred trading regions + qualityPreferences?: EnergyQuality[]; // Quality preferences +} +``` + +### Match Structure + +```typescript +interface Match { + id: string; // Unique match identifier + buyOrderId: string; // Buy order ID + sellOrderId: string; // Sell order ID + amount: number; // Matched amount + price: number; // Execution price + timestamp: number; // Match timestamp + matchingFee: number; // Matching fee amount + qualityScore: number; // Quality compatibility score + distanceScore: number; // Geographic compatibility score + priceScore: number; // Price compatibility score +} +``` + +## Events + +The matching engine emits the following events: + +### Order Events + +- **OrderCreated**: New order created +- **OrderUpdated**: Order parameters updated +- **OrderCancelled**: Order cancelled + +### Matching Events + +- **MatchExecuted**: Order match executed +- **BatchMatchingCompleted**: Batch matching completed + +### System Events + +- **EnginePaused**: Matching engine paused +- **EngineUnpaused**: Matching engine unpaused +- **EmergencyModeActivated**: Emergency mode activated + +## Performance Optimization + +### Gas Efficiency + +The matching engine is optimized for gas efficiency through: + +- **Batch Processing**: Multiple orders processed in single transactions +- **Efficient Data Structures**: Optimized order book implementation +- **Lazy Evaluation**: Calculations performed only when needed +- **Event Batching**: Multiple events emitted together + +### Performance Metrics + +The system is designed to handle: + +- **1000+ orders per block**: High-frequency trading capability +- **Sub-second matching**: Fast order execution +- **Low gas costs**: Optimized for minimal transaction fees +- **Scalable architecture**: Handles increasing order volumes + +## Security Features + +### Access Control + +- **Role-based permissions**: Different access levels for different functions +- **Admin controls**: Administrative functions protected by special permissions +- **Trader validation**: Only traders can manage their own orders + +### Market Integrity + +- **Manipulation detection**: Algorithms detect suspicious trading patterns +- **Price limits**: Built-in safeguards against extreme price movements +- **Order validation**: Comprehensive validation of all order parameters + +### Emergency Functions + +- **Emergency pause**: Ability to pause matching in emergency situations +- **Mass cancellation**: Emergency cancellation of all orders +- **Configuration overrides**: Admin can override settings in emergencies + +## Integration Examples + +### Frontend Integration + +```typescript +// React component example +const EnergyTrading = () => { + const [matchingEngine, setMatchingEngine] = useState(null); + const [orders, setOrders] = useState([]); + + useEffect(() => { + const initEngine = async () => { + const engine = new OrderMatchingEngine(adminAddress); + setMatchingEngine(engine); + }; + initEngine(); + }, []); + + const createBuyOrder = async (amount, price, location) => { + if (!matchingEngine) return; + + const orderId = await matchingEngine.createOrder( + userAddress, + OrderType.BUY, + amount, + price, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + + setOrders(prev => [...prev, orderId]); + }; + + return ( +
+ {/* Trading interface */} +
+ ); +}; +``` + +### Backend Integration + +```typescript +// Node.js backend example +app.post('/api/orders', async (req, res) => { + try { + const { trader, type, amount, price, location, quality } = req.body; + + const orderId = await matchingEngine.createOrder( + trader, + type, + amount, + price, + location, + quality, + Date.now() + 3600000 + ); + + res.json({ orderId, success: true }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +app.get('/api/orderbook', async (req, res) => { + const orderBook = await matchingEngine.getOrderBook(); + res.json(orderBook); +}); +``` + +## Testing + +### Unit Tests + +Run the comprehensive test suite: + +```bash +npm test +``` + +### Performance Tests + +Test system performance under load: + +```bash +npm run deploy test +``` + +### Integration Tests + +Test full integration with other contracts: + +```bash +npm run test:integration +``` + +## Troubleshooting + +### Common Issues + +1. **Order Not Matching**: Check price compatibility and geographic constraints +2. **High Gas Costs**: Consider using batch processing for multiple orders +3. **Slow Performance**: Verify configuration parameters and network conditions + +### Debug Mode + +Enable debug logging: + +```typescript +const engine = new OrderMatchingEngine(adminAddress); +engine.setDebugMode(true); +``` + +### Monitoring + +Monitor system health: + +```typescript +const stats = await matchingEngine.getStatistics(); +console.log('Total Orders:', stats.totalOrders); +console.log('Total Matches:', stats.totalMatches); +console.log('Average Match Time:', stats.averageMatchingTime); +``` + +## API Reference + +### Core Methods + +- `createOrder()`: Create a new order +- `cancelOrder()`: Cancel an existing order +- `updateOrder()`: Update order parameters +- `matchSingleOrder()`: Match a single order +- `matchBatch()`: Match multiple orders +- `runContinuousMatching()`: Run continuous matching + +### Query Methods + +- `getOrderBook()`: Get current order book +- `getOrdersByTrader()`: Get orders by trader +- `getOrdersByLocation()`: Get orders by location +- `getStatistics()`: Get matching statistics +- `getMarketStatistics()`: Get market statistics + +### Admin Methods + +- `updateConfig()`: Update matching configuration +- `pause()`: Pause matching engine +- `unpause()`: Unpause matching engine +- `emergencyCancelAll()`: Emergency cancel all orders + +## Contributing + +### Development Setup + +1. Clone the repository +2. Install dependencies: `npm install` +3. Run tests: `npm test` +4. Build contracts: `npm run build` + +### Code Style + +- Follow TypeScript best practices +- Use comprehensive error handling +- Include unit tests for new features +- Document all public methods + +### Pull Requests + +1. Fork the repository +2. Create feature branch +3. Add tests and documentation +4. Submit pull request with description + +## License + +This project is licensed under the MIT License. See LICENSE file for details. + +## Support + +For support and questions: + +- Create an issue in the GitHub repository +- Join the community Discord +- Check the documentation for common solutions + +## Version History + +### v1.0.0 (Current) +- Initial release +- Core matching functionality +- Geographic and quality preferences +- Performance optimizations +- Security features + +### Future Releases +- Advanced matching algorithms +- Cross-chain compatibility +- Enhanced analytics +- Mobile app integration diff --git a/scripts/deploy_matching.ts b/scripts/deploy_matching.ts new file mode 100644 index 0000000..49315b9 --- /dev/null +++ b/scripts/deploy_matching.ts @@ -0,0 +1,408 @@ +import { ethers } from 'hardhat'; +import { OrderMatchingEngine } from '../contracts/matching/OrderMatchingEngine'; +import { + MatchingConfig, + MatchingPriority, + EnergyQuality, + Location, + DEFAULT_MATCHING_CONFIG +} from '../contracts/matching/interfaces/IMatchingEngine'; + +/** + * @title Deploy Matching Engine + * @dev Deployment script for the Order Matching Engine + */ +async function main() { + console.log('๐Ÿš€ Starting Order Matching Engine deployment...\n'); + + // Get deployer account + const [deployer] = await ethers.getSigners(); + console.log(`๐Ÿ“ Deploying contracts with account: ${deployer.address}`); + console.log(`๐Ÿ’ฐ Account balance: ${ethers.utils.formatEther(await deployer.getBalance())} ETH\n`); + + try { + // Deploy OrderMatchingEngine + console.log('๐Ÿ”ง Deploying OrderMatchingEngine...'); + const OrderMatchingEngineFactory = await ethers.getContractFactory('OrderMatchingEngine'); + const matchingEngine = await OrderMatchingEngineFactory.deploy(deployer.address); + await matchingEngine.deployed(); + + console.log(`โœ… OrderMatchingEngine deployed to: ${matchingEngine.address}\n`); + + // Configure the matching engine + console.log('โš™๏ธ Configuring matching engine...'); + + const config: MatchingConfig = { + ...DEFAULT_MATCHING_CONFIG, + enabled: true, + priority: MatchingPriority.HYBRID, + maxDistance: 500, // 500 km + qualityWeight: 30, + distanceWeight: 30, + priceWeight: 40, + matchingFeeRate: 10, // 0.1% + minOrderAmount: 1, + maxOrderAmount: 1000000, + batchProcessingSize: 100, + priceTolerance: 5 // 5% + }; + + const configTx = await matchingEngine.updateConfig(config, deployer.address); + await configTx.wait(); + console.log('โœ… Matching engine configured\n'); + + // Verify configuration + const deployedConfig = await matchingEngine.getConfig(); + console.log('๐Ÿ“Š Current Configuration:'); + console.log(` Enabled: ${deployedConfig.enabled}`); + console.log(` Priority: ${deployedConfig.priority}`); + console.log(` Max Distance: ${deployedConfig.maxDistance} km`); + console.log(` Matching Fee Rate: ${deployedConfig.matchingFeeRate} basis points`); + console.log(` Batch Processing Size: ${deployedConfig.batchProcessingSize}\n`); + + // Create sample orders for testing + console.log('๐Ÿงช Creating sample orders for testing...'); + + const sampleLocation: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + // Create sample buy order + const buyOrderTx = await matchingEngine.createOrder( + deployer.address, + 0, // OrderType.BUY + 100, // amount + ethers.utils.parseUnits('50', 18), // price + sampleLocation, + 0, // EnergyQuality.STANDARD + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 10, // minFillAmount + 5, // maxPriceSlippage + ['Berlin', 'Hamburg'], // preferredRegions + [0, 1] // qualityPreferences + ); + await buyOrderTx.wait(); + console.log('โœ… Sample buy order created'); + + // Create sample sell order + const sellOrderTx = await matchingEngine.createOrder( + deployer.address, + 1, // OrderType.SELL + 100, // amount + ethers.utils.parseUnits('48', 18), // price + sampleLocation, + 0, // EnergyQuality.STANDARD + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 10, // minFillAmount + 5, // maxPriceSlippage + ['Berlin', 'Munich'], // preferredRegions + [0, 1] // qualityPreferences + ); + await sellOrderTx.wait(); + console.log('โœ… Sample sell order created\n'); + + // Test matching + console.log('๐Ÿ”„ Testing order matching...'); + const orderBook = await matchingEngine.getOrderBook(); + console.log(`๐Ÿ“ˆ Order Book Status:`); + console.log(` Bids: ${orderBook.bids.length}`); + console.log(` Asks: ${orderBook.asks.length}`); + console.log(` Spread: ${ethers.utils.formatUnits(orderBook.spread, 18)}`); + console.log(` Total Volume: ${ethers.utils.formatUnits(orderBook.totalVolume, 18)}\n`); + + // Get statistics + const stats = await matchingEngine.getStatistics(); + console.log('๐Ÿ“Š Initial Statistics:'); + console.log(` Total Orders: ${stats.totalOrders}`); + console.log(` Total Matches: ${stats.totalMatches}`); + console.log(` Total Volume: ${ethers.utils.formatUnits(stats.totalVolume, 18)}`); + console.log(` Average Match Size: ${ethers.utils.formatUnits(stats.averageMatchSize, 18)}\n`); + + // Set up geographic preferences + console.log('๐ŸŒ Setting up geographic preferences...'); + const geoPrefs = [ + { + region: 'Berlin', + priority: 10, + maxDistance: 100 + }, + { + region: 'Hamburg', + priority: 8, + maxDistance: 300 + } + ]; + + const geoPrefTx = await matchingEngine.setGeographicPreference( + deployer.address, + geoPrefs, + deployer.address + ); + await geoPrefTx.wait(); + console.log('โœ… Geographic preferences set\n'); + + // Set up quality preferences + console.log('โญ Setting up quality preferences...'); + const qualityPrefs = [ + { + quality: 2, // EnergyQuality.GREEN + minScore: 80, + premium: 10 + }, + { + quality: 3, // EnergyQuality.PREMIUM_GREEN + minScore: 90, + premium: 15 + } + ]; + + const qualityPrefTx = await matchingEngine.setQualityPreference( + deployer.address, + qualityPrefs, + deployer.address + ); + await qualityPrefTx.wait(); + console.log('โœ… Quality preferences set\n'); + + // Get deployment summary + console.log('๐Ÿ“‹ Deployment Summary:'); + console.log('========================'); + console.log(`๐Ÿ“ Contract Address: ${matchingEngine.address}`); + console.log(`๐Ÿ‘ค Deployer: ${deployer.address}`); + console.log(`โ›ฝ Gas Used: ${(await matchingEngine.deployTransaction.wait()).gasUsed.toString()}`); + console.log(`๐Ÿ”— Network: ${network.name}`); + console.log(`๐Ÿ“… Timestamp: ${new Date().toISOString()}\n`); + + // Save deployment info to file + const deploymentInfo = { + network: network.name, + deployer: deployer.address, + contractAddress: matchingEngine.address, + config: deployedConfig, + timestamp: new Date().toISOString(), + transactionHash: matchingEngine.deployTransaction.hash + }; + + const fs = require('fs'); + fs.writeFileSync( + `deployments/matching-engine-${network.name}-${Date.now()}.json`, + JSON.stringify(deploymentInfo, null, 2) + ); + console.log('๐Ÿ’พ Deployment info saved to deployments/ directory\n'); + + console.log('๐ŸŽ‰ Order Matching Engine deployed successfully!'); + console.log('๐Ÿ” You can now interact with the contract at:', matchingEngine.address); + console.log('๐Ÿ“– Check the documentation for usage examples.\n'); + + } catch (error) { + console.error('โŒ Deployment failed:', error); + process.exit(1); + } +} + +/** + * @title Verify Deployment + * @dev Script to verify the deployed contract + */ +async function verifyDeployment(contractAddress: string) { + console.log(`๐Ÿ” Verifying contract at ${contractAddress}...`); + + try { + await hre.run('verify:verify', { + address: contractAddress, + constructorArguments: [], + }); + console.log('โœ… Contract verified successfully!'); + } catch (error) { + console.error('โŒ Verification failed:', error); + } +} + +/** + * @title Upgrade Matching Engine + * @dev Script to upgrade the matching engine to a new version + */ +async function upgradeMatchingEngine(newImplementationAddress: string) { + console.log('๐Ÿ”„ Upgrading Order Matching Engine...'); + + const [deployer] = await ethers.getSigners(); + const matchingEngine = await ethers.getContractAt('OrderMatchingEngine', process.env.MATCHING_ENGINE_ADDRESS!); + + try { + const tx = await matchingEngine.upgradeTo(newImplementationAddress); + await tx.wait(); + console.log('โœ… Matching Engine upgraded successfully!'); + } catch (error) { + console.error('โŒ Upgrade failed:', error); + } +} + +/** + * @title Emergency Functions + * @dev Script to handle emergency situations + */ +async function emergencyCancelAll(reason: string) { + console.log('๐Ÿšจ Emergency cancellation of all orders...'); + + const [deployer] = await ethers.getSigners(); + const matchingEngine = await ethers.getContractAt('OrderMatchingEngine', process.env.MATCHING_ENGINE_ADDRESS!); + + try { + const tx = await matchingEngine.emergencyCancelAll(reason, deployer.address); + await tx.wait(); + console.log('โœ… All orders cancelled successfully!'); + } catch (error) { + console.error('โŒ Emergency cancellation failed:', error); + } +} + +/** + * @title Performance Testing + * @dev Script to test matching engine performance + */ +async function performanceTest() { + console.log('โšก Running performance tests...'); + + const [deployer] = await ethers.getSigners(); + const matchingEngine = await ethers.getContractAt('OrderMatchingEngine', process.env.MATCHING_ENGINE_ADDRESS!); + + const sampleLocation: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + const startTime = Date.now(); + const orderCount = 1000; + + try { + // Create multiple orders + const orderIds: string[] = []; + + for (let i = 0; i < orderCount; i++) { + const isBuy = i % 2 === 0; + const price = ethers.utils.parseUnits((45 + Math.random() * 20).toString(), 18); + + const tx = await matchingEngine.createOrder( + deployer.address, + isBuy ? 0 : 1, // OrderType + 100 + Math.floor(Math.random() * 900), // amount + price, + sampleLocation, + Math.floor(Math.random() * 4), // quality + Math.floor(Date.now() / 1000) + 3600, // expires + 10, // minFillAmount + 5, // maxPriceSlippage + ['Berlin'], // preferredRegions + [0, 1] // qualityPreferences + ); + + const receipt = await tx.wait(); + orderIds.push(receipt.events?.[0].args?.orderId); + + if (i % 100 === 0) { + console.log(`Created ${i} orders...`); + } + } + + const creationTime = Date.now() - startTime; + console.log(`โœ… Created ${orderCount} orders in ${creationTime}ms`); + console.log(`โšก Average: ${creationTime / orderCount}ms per order`); + + // Run batch matching + const matchStartTime = Date.now(); + const result = await matchingEngine.matchBatch(orderIds.slice(0, 100)); // Match first 100 + const matchTime = Date.now() - matchStartTime; + + console.log(`โœ… Batch matched 100 orders in ${matchTime}ms`); + console.log(`๐ŸŽฏ Matches found: ${result.matches.length}`); + console.log(`๐Ÿ’ฐ Total matched amount: ${ethers.utils.formatUnits(result.totalMatchedAmount, 18)}`); + + } catch (error) { + console.error('โŒ Performance test failed:', error); + } +} + +// Command line interface +if (require.main === module) { + const command = process.argv[2]; + + switch (command) { + case 'deploy': + main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + break; + + case 'verify': + const contractAddress = process.argv[3]; + if (!contractAddress) { + console.error('Please provide contract address: npm run deploy verify
'); + process.exit(1); + } + verifyDeployment(contractAddress) + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + break; + + case 'upgrade': + const newAddress = process.argv[3]; + if (!newAddress) { + console.error('Please provide new implementation address: npm run deploy upgrade
'); + process.exit(1); + } + upgradeMatchingEngine(newAddress) + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + break; + + case 'emergency': + const reason = process.argv[3] || 'Emergency maintenance'; + emergencyCancelAll(reason) + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + break; + + case 'test': + performanceTest() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + break; + + default: + console.log('Available commands:'); + console.log(' deploy - Deploy the matching engine'); + console.log(' verify
- Verify contract on Etherscan'); + console.log(' upgrade
- Upgrade to new implementation'); + console.log(' emergency [reason] - Emergency cancel all orders'); + console.log(' test - Run performance tests'); + process.exit(1); + } +} + +export { + main, + verifyDeployment, + upgradeMatchingEngine, + emergencyCancelAll, + performanceTest +}; diff --git a/tests/matching/OrderMatchingEngine.test.ts b/tests/matching/OrderMatchingEngine.test.ts new file mode 100644 index 0000000..3dc10ba --- /dev/null +++ b/tests/matching/OrderMatchingEngine.test.ts @@ -0,0 +1,821 @@ +import { OrderMatchingEngine } from '../contracts/matching/OrderMatchingEngine'; +import { + OrderType, + OrderStatus, + EnergyQuality, + Location, + MatchingConfig, + MatchingPriority, + DEFAULT_MATCHING_CONFIG +} from '../contracts/matching/interfaces/IMatchingEngine'; +import { MatchingLib } from '../contracts/matching/libraries/MatchingLib'; +import { DoubleAuction } from '../contracts/matching/algorithms/DoubleAuction'; +import { OrderBook } from '../contracts/matching/structures/OrderBook'; + +describe('OrderMatchingEngine', () => { + let engine: OrderMatchingEngine; + let admin: string; + let trader1: string; + let trader2: string; + let trader3: string; + + beforeEach(() => { + admin = '0xAdmin'; + trader1 = '0xTrader1'; + trader2 = '0xTrader2'; + trader3 = '0xTrader3'; + + engine = new OrderMatchingEngine(admin); + }); + + describe('Initialization', () => { + it('should initialize with correct default configuration', () => { + const config = engine.getConfig(); + expect(config.enabled).toBe(true); + expect(config.priority).toBe(MatchingPriority.HYBRID); + expect(config.matchingFeeRate).toBe(10); // 0.1% + }); + + it('should start with empty order book', () => { + const orderBook = engine.getOrderBook(); + expect(orderBook.bids.length).toBe(0); + expect(orderBook.asks.length).toBe(0); + }); + + it('should initialize statistics correctly', () => { + const stats = engine.getStatistics(); + expect(stats.totalOrders).toBe(0); + expect(stats.totalMatches).toBe(0); + expect(stats.totalVolume).toBe(0); + }); + }); + + describe('Order Creation', () => { + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + it('should create a valid buy order', () => { + const orderId = engine.createOrder( + trader1, + OrderType.BUY, + 100, + 50, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 // 1 hour from now + ); + + expect(orderId).toBeDefined(); + expect(orderId).toContain(trader1); + + const orderBook = engine.getOrderBook(); + expect(orderBook.bids.length).toBe(1); + expect(orderBook.asks.length).toBe(0); + }); + + it('should create a valid sell order', () => { + const orderId = engine.createOrder( + trader2, + OrderType.SELL, + 100, + 50, + location, + EnergyQuality.PREMIUM, + Date.now() + 3600000 + ); + + expect(orderId).toBeDefined(); + expect(orderId).toContain(trader2); + + const orderBook = engine.getOrderBook(); + expect(orderBook.bids.length).toBe(0); + expect(orderBook.asks.length).toBe(1); + }); + + it('should reject orders with invalid parameters', () => { + expect(() => { + engine.createOrder( + '', + OrderType.BUY, + 100, + 50, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + }).toThrow('Invalid trader address'); + + expect(() => { + engine.createOrder( + trader1, + OrderType.BUY, + -100, + 50, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + }).toThrow('Invalid amount'); + }); + + it('should emit OrderCreated event', () => { + const events = []; + engine.onOrderCreated = (event) => events.push(event); + + engine.createOrder( + trader1, + OrderType.BUY, + 100, + 50, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + + expect(events.length).toBe(1); + expect(events[0].trader).toBe(trader1); + expect(events[0].type).toBe(OrderType.BUY); + expect(events[0].amount).toBe(100); + }); + }); + + describe('Order Cancellation', () => { + let orderId: string; + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + beforeEach(() => { + orderId = engine.createOrder( + trader1, + OrderType.BUY, + 100, + 50, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + }); + + it('should allow trader to cancel their own order', () => { + const result = engine.cancelOrder(orderId, trader1); + expect(result).toBe(true); + + const orders = engine.getOrdersByTrader(trader1); + expect(orders[0].status).toBe(OrderStatus.CANCELLED); + }); + + it('should allow admin to cancel any order', () => { + const result = engine.cancelOrder(orderId, admin); + expect(result).toBe(true); + + const orders = engine.getOrdersByTrader(trader1); + expect(orders[0].status).toBe(OrderStatus.CANCELLED); + }); + + it('should reject cancellation by unauthorized trader', () => { + expect(() => { + engine.cancelOrder(orderId, trader2); + }).toThrow('INSUFFICIENT_PERMISSIONS'); + }); + + it('should emit OrderCancelled event', () => { + const events = []; + engine.onOrderCancelled = (event) => events.push(event); + + engine.cancelOrder(orderId, trader1); + + expect(events.length).toBe(1); + expect(events[0].orderId).toBe(orderId); + expect(events[0].trader).toBe(trader1); + }); + }); + + describe('Order Matching', () => { + let buyOrderId: string; + let sellOrderId: string; + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + beforeEach(() => { + // Create matching buy and sell orders + buyOrderId = engine.createOrder( + trader1, + OrderType.BUY, + 100, + 60, // Higher price + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + + sellOrderId = engine.createOrder( + trader2, + OrderType.SELL, + 100, + 50, // Lower price + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + }); + + it('should match compatible orders', () => { + const matches = engine.matchSingleOrder(buyOrderId); + expect(matches.length).toBe(1); + expect(matches[0].amount).toBe(100); + expect(matches[0].price).toBe(50); // Sell price (maker price) + }); + + it('should update order statuses after matching', () => { + engine.matchSingleOrder(buyOrderId); + + const buyOrder = engine.getOrdersByTrader(trader1)[0]; + const sellOrder = engine.getOrdersByTrader(trader2)[0]; + + expect(buyOrder.status).toBe(OrderStatus.FILLED); + expect(sellOrder.status).toBe(OrderStatus.FILLED); + expect(buyOrder.filledAmount).toBe(100); + expect(sellOrder.filledAmount).toBe(100); + }); + + it('should handle partial fills', () => { + // Create orders with different amounts + const partialBuyId = engine.createOrder( + trader3, + OrderType.BUY, + 150, + 60, + location, + EnergyQuality.STANDARD, + Date.now() + 3600000 + ); + + const matches = engine.matchSingleOrder(partialBuyId); + expect(matches.length).toBe(1); + expect(matches[0].amount).toBe(100); // Only 100 available to match + + const buyOrder = engine.getOrdersByTrader(trader3)[0]; + expect(buyOrder.status).toBe(OrderStatus.PARTIALLY_FILLED); + expect(buyOrder.filledAmount).toBe(100); + }); + + it('should emit MatchExecuted event', () => { + const events = []; + engine.onMatchExecuted = (event) => events.push(event); + + engine.matchSingleOrder(buyOrderId); + + expect(events.length).toBe(1); + expect(events[0].buyOrderId).toBe(buyOrderId); + expect(events[0].sellOrderId).toBe(sellOrderId); + expect(events[0].amount).toBe(100); + }); + }); + + describe('Batch Matching', () => { + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + it('should match multiple orders in batch', () => { + const orderIds: string[] = []; + + // Create multiple orders + orderIds.push(engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000)); + orderIds.push(engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000)); + orderIds.push(engine.createOrder(trader3, OrderType.BUY, 80, 55, location, EnergyQuality.PREMIUM, Date.now() + 3600000)); + + const result = engine.matchBatch(orderIds); + + expect(result.matches.length).toBeGreaterThan(0); + expect(result.totalMatchedAmount).toBeGreaterThan(0); + }); + + it('should emit BatchMatchingCompleted event', () => { + const events = []; + engine.onBatchMatchingCompleted = (event) => events.push(event); + + const orderIds: string[] = []; + orderIds.push(engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000)); + orderIds.push(engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000)); + + engine.matchBatch(orderIds); + + expect(events.length).toBe(1); + expect(events[0].matchesCount).toBeGreaterThan(0); + }); + }); + + describe('Continuous Matching', () => { + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + it('should run continuous matching on all orders', () => { + // Create multiple orders + engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000); + engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000); + engine.createOrder(trader3, OrderType.BUY, 80, 55, location, EnergyQuality.PREMIUM, Date.now() + 3600000); + engine.createOrder('0xTrader4', OrderType.SELL, 80, 45, location, EnergyQuality.PREMIUM, Date.now() + 3600000); + + const result = engine.runContinuousMatching(); + + expect(result.matches.length).toBeGreaterThan(0); + expect(result.totalMatchedAmount).toBeGreaterThan(0); + expect(result.processingTime).toBeGreaterThan(0); + }); + }); + + describe('Configuration Management', () => { + it('should allow admin to update configuration', () => { + const newConfig: Partial = { + matchingFeeRate: 20, // 0.2% + maxDistance: 1000 + }; + + const result = engine.updateConfig(newConfig, admin); + expect(result).toBe(true); + + const config = engine.getConfig(); + expect(config.matchingFeeRate).toBe(20); + expect(config.maxDistance).toBe(1000); + }); + + it('should reject configuration updates from non-admin', () => { + expect(() => { + engine.updateConfig({ matchingFeeRate: 20 }, trader1); + }).toThrow('INSUFFICIENT_PERMISSIONS'); + }); + + it('should validate configuration parameters', () => { + expect(() => { + engine.updateConfig({ matchingFeeRate: -10 }, admin); + }).toThrow('Invalid matching fee rate'); + + expect(() => { + engine.updateConfig({ maxDistance: -100 }, admin); + }).toThrow('Invalid max distance'); + }); + }); + + describe('Geographic and Quality Preferences', () => { + it('should set geographic preferences for trader', () => { + const preferences = [ + { + region: 'Berlin', + priority: 10, + maxDistance: 100 + } + ]; + + const result = engine.setGeographicPreference(trader1, preferences, trader1); + expect(result).toBe(true); + }); + + it('should set quality preferences for trader', () => { + const preferences = [ + { + quality: EnergyQuality.GREEN, + minScore: 80, + premium: 10 + } + ]; + + const result = engine.setQualityPreference(trader1, preferences, trader1); + expect(result).toBe(true); + }); + + it('should reject preference updates from unauthorized users', () => { + const preferences = [{ region: 'Berlin', priority: 10, maxDistance: 100 }]; + + expect(() => { + engine.setGeographicPreference(trader1, preferences, trader2); + }).toThrow('INSUFFICIENT_PERMISSIONS'); + }); + }); + + describe('Statistics and Analytics', () => { + const location: Location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + it('should track order statistics', () => { + engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000); + engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000); + + const stats = engine.getStatistics(); + expect(stats.totalOrders).toBe(2); + }); + + it('should track match statistics', () => { + const buyOrderId = engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000); + const sellOrderId = engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000); + + engine.matchSingleOrder(buyOrderId); + + const stats = engine.getStatistics(); + expect(stats.totalMatches).toBe(1); + expect(stats.totalVolume).toBe(100); + }); + + it('should provide market statistics', () => { + engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000); + engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000); + + const marketStats = engine.getMarketStatistics(); + expect(marketStats.bestBid).toBe(60); + expect(marketStats.bestAsk).toBe(50); + expect(marketStats.spread).toBe(10); + }); + }); + + describe('Admin Functions', () => { + it('should allow admin to pause and unpause', () => { + expect(engine.pause(admin)).toBe(true); + + // Should throw when paused + expect(() => { + engine.createOrder(trader1, OrderType.BUY, 100, 50, { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }, EnergyQuality.STANDARD, Date.now() + 3600000); + }).toThrow('Matching engine is paused'); + + expect(engine.unpause(admin)).toBe(true); + }); + + it('should allow emergency cancellation of all orders', () => { + const location = { + latitude: 52.5200, + longitude: 13.4050, + region: 'Berlin', + country: 'Germany' + }; + + engine.createOrder(trader1, OrderType.BUY, 100, 60, location, EnergyQuality.STANDARD, Date.now() + 3600000); + engine.createOrder(trader2, OrderType.SELL, 100, 50, location, EnergyQuality.STANDARD, Date.now() + 3600000); + + const result = engine.emergencyCancelAll('Test emergency', admin); + expect(result).toBe(true); + + const orderBook = engine.getOrderBook(); + expect(orderBook.bids.length).toBe(0); + expect(orderBook.asks.length).toBe(0); + }); + }); + + describe('Fee Calculation', () => { + it('should calculate matching fees correctly', () => { + const fee = engine.calculateMatchingFee(100, 50); // 100 units at 50 price each + expect(fee).toBe(5); // 100 * 50 * 10 / 10000 = 5 + }); + + it('should distribute fees correctly', () => { + const result = engine.distributeMatchingFees(100); + expect(result).toBe(true); + + const stats = engine.getStatistics(); + expect(stats.totalMatchingFees).toBe(100); + }); + }); +}); + +describe('MatchingLib', () => { + describe('Distance Calculation', () => { + it('should calculate distance between two locations', () => { + const loc1 = { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }; + const loc2 = { latitude: 48.8566, longitude: 2.3522, region: 'Paris', country: 'France' }; + + const distance = MatchingLib.calculateDistance(loc1, loc2); + expect(distance).toBeGreaterThan(800); // Berlin to Paris is ~878 km + expect(distance).toBeLessThan(1000); + }); + + it('should return zero distance for same location', () => { + const location = { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }; + + const distance = MatchingLib.calculateDistance(location, location); + expect(distance).toBe(0); + }); + }); + + describe('Quality Scoring', () => { + it('should give perfect score for equal or better quality', () => { + expect(MatchingLib.calculateQualityScore(EnergyQuality.STANDARD, EnergyQuality.STANDARD)).toBe(100); + expect(MatchingLib.calculateQualityScore(EnergyQuality.STANDARD, EnergyQuality.PREMIUM)).toBe(100); + expect(MatchingLib.calculateQualityScore(EnergyQuality.STANDARD, EnergyQuality.PREMIUM_GREEN)).toBe(100); + }); + + it('should penalize lower quality offerings', () => { + expect(MatchingLib.calculateQualityScore(EnergyQuality.PREMIUM, EnergyQuality.STANDARD)).toBe(75); + expect(MatchingLib.calculateQualityScore(EnergyQuality.PREMIUM_GREEN, EnergyQuality.STANDARD)).toBe(25); + }); + }); + + describe('Price Scoring', () => { + it('should give high score for tight spreads', () => { + const score = MatchingLib.calculatePriceScore(51, 50); // 2% spread + expect(score).toBeGreaterThan(90); + }); + + it('should give zero score for non-matching prices', () => { + const score = MatchingLib.calculatePriceScore(49, 50); // Below ask price + expect(score).toBe(0); + }); + }); + + describe('Order Validation', () => { + it('should validate correct orders', () => { + const order = { + trader: '0xTrader', + type: OrderType.BUY, + amount: 100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + expiresAt: Date.now() + 3600000 + }; + + const validation = MatchingLib.validateOrder(order); + expect(validation.isValid).toBe(true); + expect(validation.errors.length).toBe(0); + }); + + it('should detect invalid orders', () => { + const order = { + trader: '', + type: OrderType.BUY, + amount: -100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + expiresAt: Date.now() - 3600000 // Expired + }; + + const validation = MatchingLib.validateOrder(order); + expect(validation.isValid).toBe(false); + expect(validation.errors.length).toBeGreaterThan(0); + }); + }); +}); + +describe('DoubleAuction', () => { + describe('Market Statistics', () => { + it('should calculate market statistics correctly', () => { + const buyOrders = [ + { + id: 'buy1', + trader: 'trader1', + type: OrderType.BUY, + amount: 100, + price: 60, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + } + ]; + + const sellOrders = [ + { + id: 'sell1', + trader: 'trader2', + type: OrderType.SELL, + amount: 100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + } + ]; + + const stats = DoubleAuction.calculateMarketStatistics(buyOrders, sellOrders); + expect(stats.bestBid).toBe(60); + expect(stats.bestAsk).toBe(50); + expect(stats.spread).toBe(10); + expect(stats.midPrice).toBe(55); + }); + }); + + describe('Price Impact Simulation', () => { + it('should simulate price impact for large orders', () => { + const orderBook = [ + { + id: 'sell1', + trader: 'trader2', + type: OrderType.SELL, + amount: 100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }, + { + id: 'sell2', + trader: 'trader3', + type: OrderType.SELL, + amount: 100, + price: 55, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + } + ]; + + const order = { + id: 'buy1', + trader: 'trader1', + type: OrderType.BUY, + amount: 150, + price: 60, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + const impact = DoubleAuction.simulatePriceImpact(order, orderBook, DEFAULT_MATCHING_CONFIG); + expect(impact.estimatedPrice).toBeGreaterThan(50); + expect(impact.fillProbability).toBeGreaterThan(0); + }); + }); + + describe('Market Manipulation Detection', () => { + it('should detect suspicious trading patterns', () => { + const orders = [ + { + id: 'large1', + trader: 'trader1', + type: OrderType.BUY, + amount: 100000, // Unusually large + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.CANCELLED, + filledAmount: 0, + createdAt: Date.now() - 1000, // Cancelled quickly + expiresAt: Date.now() + 3600000 + } + ]; + + const detection = DoubleAuction.detectMarketManipulation(orders); + expect(detection.reasons.length).toBeGreaterThan(0); + expect(detection.confidence).toBeGreaterThan(0); + }); + }); +}); + +describe('OrderBook', () => { + let orderBook: OrderBook; + + beforeEach(() => { + orderBook = new OrderBook(); + }); + + describe('Order Management', () => { + it('should add and remove orders correctly', () => { + const order = { + id: 'order1', + trader: 'trader1', + type: OrderType.BUY, + amount: 100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + expect(orderBook.addOrder(order)).toBe(true); + expect(orderBook.getOrder('order1')).toBeDefined(); + + expect(orderBook.removeOrder('order1')).toBe(true); + expect(orderBook.getOrder('order1')).toBeUndefined(); + }); + + it('should maintain sorted order book', () => { + const order1 = { + id: 'order1', + trader: 'trader1', + type: OrderType.BUY, + amount: 100, + price: 60, // Higher price + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + const order2 = { + id: 'order2', + trader: 'trader2', + type: OrderType.BUY, + amount: 100, + price: 50, // Lower price + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + orderBook.addOrder(order2); + orderBook.addOrder(order1); + + // For buy orders, higher price should come first + expect(orderBook.bids[0].price).toBe(60); + expect(orderBook.bids[1].price).toBe(50); + }); + }); + + describe('Market Depth', () => { + it('should calculate market depth correctly', () => { + const order1 = { + id: 'order1', + trader: 'trader1', + type: OrderType.BUY, + amount: 100, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + const order2 = { + id: 'order2', + trader: 'trader2', + type: OrderType.BUY, + amount: 50, + price: 50, + location: { latitude: 52.5200, longitude: 13.4050, region: 'Berlin', country: 'Germany' }, + quality: EnergyQuality.STANDARD, + status: OrderStatus.PENDING, + filledAmount: 0, + createdAt: Date.now(), + expiresAt: Date.now() + 3600000 + }; + + orderBook.addOrder(order1); + orderBook.addOrder(order2); + + const depth = orderBook.getMarketDepth(OrderType.BUY, 5); + expect(depth[0].price).toBe(50); + expect(depth[0].amount).toBe(150); // 100 + 50 + expect(depth[0].count).toBe(2); + }); + }); + + describe('Statistics', () => { + it('should calculate order book statistics correctly', () => { + const stats = orderBook.getStatistics(); + expect(stats.totalOrders).toBe(0); + expect(stats.buyOrders).toBe(0); + expect(stats.sellOrders).toBe(0); + expect(stats.totalVolume).toBe(0); + }); + }); +});