Files
makemd/server/src/services/BiddingStrategyService.ts

426 lines
13 KiB
TypeScript
Raw Normal View History

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<BiddingStrategyResult> {
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<void> {
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<void> {
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<BidHistory[]> {
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;
}
}