import db from '../config/database'; import { logger } from '../utils/logger'; import { AuditService } from './AuditService'; interface CompetitionData { adId: string; platform: string; keyword: string; currentBid: number; avgCompetitorBid: number; minCompetitorBid: number; maxCompetitorBid: number; competitionLevel: 'LOW' | 'MEDIUM' | 'HIGH'; auctionInsights: { impressionShare: number; topImpressionShare: number; outrankedShare: number; }; } interface BiddingRecommendation { keyword: string; currentBid: number; recommendedBid: number; bidChange: number; bidChangePercent: number; strategy: 'INCREASE' | 'DECREASE' | 'MAINTAIN'; reasoning: string; expectedImpact: { impressions: number; clicks: number; cost: number; }; confidence: 'HIGH' | 'MEDIUM' | 'LOW'; } interface BiddingStrategyResult { adId: string; platform: string; totalBudget: number; recommendations: BiddingRecommendation[]; overallStrategy: 'AGGRESSIVE' | 'MODERATE' | 'CONSERVATIVE'; estimatedBudgetChange: number; traceId: string; } interface BidHistory { bidId: string; adId: string; keyword: string; oldBid: number; newBid: number; performance: { impressions: number; clicks: number; conversions: number; cost: number; }; timestamp: Date; } export class BiddingStrategyService { private static readonly TABLE_BID_HISTORY = 'cf_bid_history'; private static readonly TABLE_BIDDING_RECOMMENDATIONS = 'cf_bidding_recommendations'; private static readonly BID_ADJUSTMENT_RULES = { LOW_IMPRESSION_SHARE: { threshold: 0.3, bidIncrease: 0.15, }, HIGH_IMPRESSION_SHARE: { threshold: 0.8, bidDecrease: 0.10, }, LOW_TOP_SHARE: { threshold: 0.2, bidIncrease: 0.20, }, HIGH_OUTRANKED_SHARE: { threshold: 0.5, bidIncrease: 0.25, }, }; private static readonly MAX_BID_INCREASE = 0.50; private static readonly MAX_BID_DECREASE = 0.30; static async generateBiddingStrategy( adId: string, competitionData: CompetitionData[], options: { tenantId: string; shopId: string; totalBudget: number; targetRoas?: number; targetCpa?: number; taskId: string; traceId: string; businessType: 'TOC' | 'TOB'; } ): Promise { const { tenantId, shopId, totalBudget, targetRoas, targetCpa, taskId, traceId, businessType } = options; logger.info(`[BiddingStrategyService] Generating bidding strategy: ${JSON.stringify({ adId, tenantId, shopId, keywordCount: competitionData.length, totalBudget, targetRoas, targetCpa, traceId, businessType, })}`); const recommendations: BiddingRecommendation[] = []; let totalCurrentBid = 0; let totalRecommendedBid = 0; for (const data of competitionData) { const recommendation = this.calculateBidRecommendation(data, targetRoas, targetCpa); recommendations.push(recommendation); totalCurrentBid += data.currentBid; totalRecommendedBid += recommendation.recommendedBid; } const overallStrategy = this.determineOverallStrategy(recommendations, totalBudget); const result: BiddingStrategyResult = { adId, platform: competitionData[0]?.platform || 'UNKNOWN', totalBudget, recommendations, overallStrategy, estimatedBudgetChange: totalRecommendedBid - totalCurrentBid, traceId, }; await this.saveBiddingRecommendations(result, tenantId, shopId, taskId, traceId, businessType); logger.info(`[BiddingStrategyService] Bidding strategy generated: ${JSON.stringify({ adId, recommendationCount: recommendations.length, overallStrategy, estimatedBudgetChange: result.estimatedBudgetChange, traceId, })}`); return result; } private static calculateBidRecommendation( data: CompetitionData, targetRoas?: number, targetCpa?: number ): BiddingRecommendation { let recommendedBid = data.currentBid; let strategy: 'INCREASE' | 'DECREASE' | 'MAINTAIN' = 'MAINTAIN'; let reasoning = ''; let confidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'MEDIUM'; const { impressionShare, topImpressionShare, outrankedShare } = data.auctionInsights; if (impressionShare < this.BID_ADJUSTMENT_RULES.LOW_IMPRESSION_SHARE.threshold) { const increase = this.BID_ADJUSTMENT_RULES.LOW_IMPRESSION_SHARE.bidIncrease; recommendedBid = data.currentBid * (1 + increase); strategy = 'INCREASE'; reasoning = `Low impression share (${(impressionShare * 100).toFixed(1)}%). Increasing bid to improve visibility.`; confidence = 'HIGH'; } else if (topImpressionShare < this.BID_ADJUSTMENT_RULES.LOW_TOP_SHARE.threshold) { const increase = this.BID_ADJUSTMENT_RULES.LOW_TOP_SHARE.bidIncrease; recommendedBid = data.currentBid * (1 + increase); strategy = 'INCREASE'; reasoning = `Low top impression share (${(topImpressionShare * 100).toFixed(1)}%). Increasing bid for top positions.`; confidence = 'HIGH'; } else if (outrankedShare > this.BID_ADJUSTMENT_RULES.HIGH_OUTRANKED_SHARE.threshold) { const increase = this.BID_ADJUSTMENT_RULES.HIGH_OUTRANKED_SHARE.bidIncrease; recommendedBid = data.currentBid * (1 + increase); strategy = 'INCREASE'; reasoning = `High outranked share (${(outrankedShare * 100).toFixed(1)}%). Competitors are outbidding.`; confidence = 'MEDIUM'; } else if (impressionShare > this.BID_ADJUSTMENT_RULES.HIGH_IMPRESSION_SHARE.threshold) { const decrease = this.BID_ADJUSTMENT_RULES.HIGH_IMPRESSION_SHARE.bidDecrease; recommendedBid = data.currentBid * (1 - decrease); strategy = 'DECREASE'; reasoning = `High impression share (${(impressionShare * 100).toFixed(1)}%). Opportunity to reduce costs.`; confidence = 'MEDIUM'; } else { reasoning = `Current bid is optimal for current market conditions.`; confidence = 'LOW'; } if (data.competitionLevel === 'HIGH') { recommendedBid = Math.max(recommendedBid, data.avgCompetitorBid * 1.1); if (strategy === 'MAINTAIN') { strategy = 'INCREASE'; reasoning = 'High competition market requires competitive bidding.'; } } if (targetCpa && data.currentBid > targetCpa * 0.5) { recommendedBid = Math.min(recommendedBid, targetCpa * 0.4); if (strategy === 'INCREASE') { strategy = 'DECREASE'; reasoning = `Bid exceeds target CPA ($${targetCpa}). Reducing to meet target.`; } } const maxBid = data.currentBid * (1 + this.MAX_BID_INCREASE); const minBid = data.currentBid * (1 - this.MAX_BID_DECREASE); recommendedBid = Math.max(minBid, Math.min(maxBid, recommendedBid)); const bidChange = recommendedBid - data.currentBid; const bidChangePercent = (bidChange / data.currentBid) * 100; const expectedImpressions = Math.round(data.auctionInsights.impressionShare * 10000 * (1 + bidChangePercent / 100)); const expectedClicks = Math.round(expectedImpressions * 0.02); const expectedCost = expectedClicks * recommendedBid; return { keyword: data.keyword, currentBid: data.currentBid, recommendedBid: Math.round(recommendedBid * 100) / 100, bidChange: Math.round(bidChange * 100) / 100, bidChangePercent: Math.round(bidChangePercent * 100) / 100, strategy, reasoning, expectedImpact: { impressions: expectedImpressions, clicks: expectedClicks, cost: Math.round(expectedCost * 100) / 100, }, confidence, }; } private static determineOverallStrategy( recommendations: BiddingRecommendation[], totalBudget: number ): 'AGGRESSIVE' | 'MODERATE' | 'CONSERVATIVE' { const increaseCount = recommendations.filter(r => r.strategy === 'INCREASE').length; const decreaseCount = recommendations.filter(r => r.strategy === 'DECREASE').length; const totalBudgetChange = recommendations.reduce((sum, r) => sum + r.bidChange, 0); const increaseRatio = increaseCount / recommendations.length; if (increaseRatio > 0.6 && totalBudgetChange > totalBudget * 0.1) { return 'AGGRESSIVE'; } else if (decreaseCount > increaseCount) { return 'CONSERVATIVE'; } return 'MODERATE'; } private static async saveBiddingRecommendations( result: BiddingStrategyResult, tenantId: string, shopId: string, taskId: string, traceId: string, businessType: 'TOC' | 'TOB' ): Promise { try { const records = result.recommendations.map(rec => ({ id: `BID-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, tenant_id: tenantId, shop_id: shopId, ad_id: result.adId, keyword: rec.keyword, current_bid: rec.currentBid, recommended_bid: rec.recommendedBid, strategy: rec.strategy, confidence: rec.confidence, reasoning: rec.reasoning, overall_strategy: result.overallStrategy, trace_id: traceId, task_id: taskId, business_type: businessType, created_at: new Date(), })); await db(this.TABLE_BIDDING_RECOMMENDATIONS).insert(records); await AuditService.log({ action: 'BIDDING_STRATEGY_GENERATED', resourceType: 'bidding_strategy', resourceId: result.adId, tenantId, shopId, taskId, traceId, userId: 'SYSTEM', module: 'bidding_strategy', result: 'success', source: 'console', metadata: { overallStrategy: result.overallStrategy, recommendationCount: result.recommendations.length, estimatedBudgetChange: result.estimatedBudgetChange, }, }); } catch (error: any) { logger.error(`[BiddingStrategyService] Failed to save recommendations: ${JSON.stringify({ adId: result.adId, error: error.message, traceId, })}`); } } static async applyBiddingStrategy( adId: string, recommendations: BiddingRecommendation[], options: { tenantId: string; shopId: string; taskId: string; traceId: string; businessType: 'TOC' | 'TOB'; } ): Promise<{ applied: number; failed: number }> { const { tenantId, shopId, taskId, traceId, businessType } = options; logger.info(`[BiddingStrategyService] Applying bidding strategy: ${JSON.stringify({ adId, tenantId, shopId, recommendationCount: recommendations.length, traceId, })}`); let applied = 0; let failed = 0; for (const rec of recommendations) { try { await this.updateBid(adId, rec, tenantId, shopId, taskId, traceId, businessType); applied++; } catch (error: any) { logger.error(`[BiddingStrategyService] Failed to apply bid: ${JSON.stringify({ adId, keyword: rec.keyword, error: error.message, traceId, })}`); failed++; } } await AuditService.log({ action: 'BIDDING_STRATEGY_APPLIED', resourceType: 'bidding_strategy', resourceId: adId, tenantId, shopId, taskId, traceId, userId: 'SYSTEM', module: 'bidding_strategy', result: 'success', source: 'console', metadata: { applied, failed }, }); return { applied, failed }; } private static async updateBid( adId: string, recommendation: BiddingRecommendation, tenantId: string, shopId: string, taskId: string, traceId: string, businessType: 'TOC' | 'TOB' ): Promise { await db(this.TABLE_BID_HISTORY).insert({ id: `BIDH-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, tenant_id: tenantId, shop_id: shopId, ad_id: adId, keyword: recommendation.keyword, old_bid: recommendation.currentBid, new_bid: recommendation.recommendedBid, strategy: recommendation.strategy, trace_id: traceId, task_id: taskId, business_type: businessType, created_at: new Date(), }); logger.info(`[BiddingStrategyService] Bid updated: ${JSON.stringify({ adId, keyword: recommendation.keyword, oldBid: recommendation.currentBid, newBid: recommendation.recommendedBid, traceId, })}`); } static async getBidHistory( adId: string, options: { tenantId: string; limit?: number; traceId: string; } ): Promise { const { tenantId, limit = 30, traceId } = options; const history = await db(this.TABLE_BID_HISTORY) .where({ ad_id: adId, tenant_id: tenantId }) .orderBy('created_at', 'desc') .limit(limit); logger.info(`[BiddingStrategyService] Retrieved bid history: ${JSON.stringify({ adId, count: history.length, traceId, })}`); return history; } }