426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|