Files
AdPlatform-Server/Gateway/Services/ForecastService.cs
2026-03-14 13:50:09 -07:00

479 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Gateway.Models;
using System.Diagnostics;
using System.Text.Json;
namespace Gateway.Services;
/// <summary>
/// Fans out to provider services for forecast estimates, normalizes the responses,
/// scores them by objective, and derives recommended allocation percentages.
///
/// Called by ForecastController for the wizard budget step.
/// Same capability can serve admin seed workflow later.
/// </summary>
public sealed class ForecastService
{
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly ILogger<ForecastService> _logger;
private const int MIN_ALLOCATION = 15;
private const int MAX_ALLOCATION = 85;
public ForecastService(IHttpClientFactory http, IConfiguration cfg, ILogger<ForecastService> logger)
{
_http = http;
_cfg = cfg;
_logger = logger;
}
/// <summary>
/// Generate forecast estimates across requested channels and return
/// normalized comparison with recommended allocation.
/// </summary>
public async Task<ChannelForecastResponse> ForecastAsync(ChannelForecastRequest request, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var channels = request.Channels ?? new List<string> { "google_ads" };
var weights = ObjectiveWeights.For(request.Objective);
_logger.LogInformation(
"[Forecast] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
request.Objective, request.MonthlyBudget, string.Join(",", channels));
// ── Fan out to providers in parallel ──
var tasks = new Dictionary<string, Task<ProviderForecastResult>>();
foreach (var channel in channels)
{
tasks[channel] = channel switch
{
"google_ads" => FetchGoogleForecastAsync(request, ct),
"meta" => FetchMetaForecastAsync(request, ct),
"tiktok" => Task.FromResult(TemplateForecast("tiktok", request.MonthlyBudget, channels.Count)),
_ => Task.FromResult(TemplateForecast(channel, request.MonthlyBudget, channels.Count))
};
}
await Task.WhenAll(tasks.Values);
// ── Collect results ──
var results = new Dictionary<string, ProviderForecastResult>();
foreach (var (channel, task) in tasks)
{
results[channel] = task.Result;
}
// ── Score and derive allocation ──
var scored = ScoreChannels(results, weights);
var allocations = DeriveAllocations(scored);
// ── Build response ──
var channelEstimates = new List<ChannelEstimate>();
foreach (var (channel, result) in results)
{
var pct = allocations[channel];
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
channelEstimates.Add(new ChannelEstimate
{
Provider = channel,
AllocationPercent = pct,
AllocatedBudget = allocated,
Estimates = new ChannelEstimateMetrics
{
Impressions = result.Impressions,
Reach = result.Reach,
Clicks = result.Clicks,
Conversions = result.Conversions,
AvgCpc = result.AvgCpc,
AvgCpm = result.AvgCpm,
EstimatedCpa = result.EstimatedCpa,
Ctr = result.Ctr
},
EfficiencyScore = Math.Round(scored[channel], 3),
StrengthLabel = GetStrengthLabel(channel, request.Objective),
Confidence = result.Confidence,
DataSource = result.DataSource
});
}
// Sort by allocation descending
channelEstimates.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
sw.Stop();
_logger.LogInformation("[Forecast] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
return new ChannelForecastResponse
{
Ok = true,
Objective = request.Objective,
TotalBudget = request.MonthlyBudget,
Channels = channelEstimates,
Recommendation = BuildRecommendation(channelEstimates, request.Objective),
Metadata = new ForecastMeta
{
GeneratedAt = DateTimeOffset.UtcNow,
ForecastPeriod = "30 days"
}
};
}
// ════════════════════════════════════════════════
// Provider calls
// ════════════════════════════════════════════════
private async Task<ProviderForecastResult> FetchGoogleForecastAsync(
ChannelForecastRequest request, CancellationToken ct)
{
try
{
var providerUrl = _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "";
var key = _cfg["GOOGLE_INTERNAL_KEY"] ?? "";
if (string.IsNullOrWhiteSpace(providerUrl))
return EmulatedGoogleFallback(request);
// Build provider request matching existing ProviderRequest pattern
var payload = new
{
keywords = request.Keywords,
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
monthlyBudget = request.MonthlyBudget,
currencyCode = "USD",
forecastDays = 30
};
var providerRequest = new
{
operation = "KeywordForecast",
requestId = Guid.NewGuid().ToString("N"),
payload
};
var httpClient = _http.CreateClient();
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
msg.Headers.Add("X-Internal-Key", key);
msg.Content = new StringContent(
JsonSerializer.Serialize(providerRequest, _jsonOpts),
System.Text.Encoding.UTF8, "application/json");
using var resp = await httpClient.SendAsync(msg, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("[Forecast] Google provider returned {Status}", (int)resp.StatusCode);
return EmulatedGoogleFallback(request);
}
// Parse provider response: { ok, data: { provider, monthly, metrics, ... } }
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var data = root.TryGetProperty("data", out var d) ? d : root;
var monthly = data.GetProperty("monthly");
var metrics = data.GetProperty("metrics");
return new ProviderForecastResult
{
Provider = "google_ads",
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
Reach = null, // Google Search doesn't provide reach
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
? cpa.GetDecimal() : null,
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[Forecast] Google provider call failed");
return EmulatedGoogleFallback(request);
}
}
private async Task<ProviderForecastResult> FetchMetaForecastAsync(
ChannelForecastRequest request, CancellationToken ct)
{
// TODO Phase 2: Call MetaApi /internal/execute with DeliveryEstimate operation
// For now, return realistic emulated Meta estimates
await Task.CompletedTask;
var budget = request.MonthlyBudget;
var rng = new Random((int)(budget * 77));
var v = 0.85 + (rng.NextDouble() * 0.30);
// Meta: strong reach/impressions, moderate clicks, lower CPC than Google
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); // $12.50 $20.50
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
var reach = impressions * 0.42; // ~2.4 frequency
var clickRate = 0.012 + (rng.NextDouble() * 0.008); // 1.2% 2.0% CTR
var clicks = impressions * clickRate;
var convRate = 0.025 + (rng.NextDouble() * 0.015);
var conversions = clicks * convRate;
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
var cpa = conversions > 0 ? budget / (decimal)conversions : (decimal?)null;
return new ProviderForecastResult
{
Provider = "meta",
Impressions = Math.Round(impressions),
Clicks = Math.Round(clicks),
Conversions = Math.Round(conversions, 1),
Reach = Math.Round(reach),
AvgCpc = Math.Round(avgCpc, 2),
AvgCpm = Math.Round(cpm, 2),
Ctr = Math.Round(clickRate, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
Confidence = "low",
DataSource = "emulated"
};
}
/// <summary>Template-only fallback for channels without API forecasting (e.g., TikTok)</summary>
private static ProviderForecastResult TemplateForecast(string provider, decimal totalBudget, int channelCount)
{
return new ProviderForecastResult
{
Provider = provider,
Confidence = "none",
DataSource = "template"
};
}
/// <summary>Client-side Google emulation when provider is unreachable</summary>
private ProviderForecastResult EmulatedGoogleFallback(ChannelForecastRequest request)
{
var budget = request.MonthlyBudget;
var kwCount = Math.Max(request.Keywords.Count, 1);
var rng = new Random((int)(budget * 100) + kwCount);
var v = 0.85 + (rng.NextDouble() * 0.30);
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
var impressions = clicks / 0.045;
var conversions = clicks * 0.035;
var ctr = impressions > 0 ? clicks / impressions : 0;
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
return new ProviderForecastResult
{
Provider = "google_ads",
Impressions = Math.Round(impressions),
Clicks = Math.Round(clicks),
Conversions = Math.Round(conversions, 1),
AvgCpc = Math.Round(baseCpc, 2),
AvgCpm = Math.Round((decimal)cpm, 2),
Ctr = Math.Round(ctr, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
Confidence = "low",
DataSource = "emulated"
};
}
// ════════════════════════════════════════════════
// Scoring: objective-weighted efficiency
// ════════════════════════════════════════════════
private static Dictionary<string, double> ScoreChannels(
Dictionary<string, ProviderForecastResult> results, MetricWeights w)
{
// Only score channels that have real estimates
var scoreable = results
.Where(r => r.Value.DataSource != "template")
.ToDictionary(r => r.Key, r => r.Value);
if (scoreable.Count == 0)
return results.ToDictionary(r => r.Key, _ => 1.0);
// For each "more is better" metric: normalize to 01 (best = 1.0)
// For each "less is better" metric: invert (lowest = 1.0)
//double Norm(Func<ProviderForecastResult, double> selector, bool invert = false)
//{
// Not used directly — we normalize per-channel below
// return 0;
// }
var scores = new Dictionary<string, double>();
// Find max/min across scoreable channels for normalization
var maxImp = scoreable.Values.Max(r => r.Impressions);
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
var maxClicks = scoreable.Values.Max(r => r.Clicks);
var maxConv = scoreable.Values.Max(r => r.Conversions);
var maxCtr = scoreable.Values.Max(r => r.Ctr);
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0).Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
foreach (var (channel, r) in scoreable)
{
double score = 0;
// "More is better" — value / max
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
// "Less is better" — min / value
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
scores[channel] = score;
}
// Template-only channels get the average score
var avgScore = scores.Values.Average();
foreach (var channel in results.Keys.Except(scoreable.Keys))
{
scores[channel] = avgScore * 0.5; // Slight penalty for no data
}
return scores;
}
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
{
var total = scores.Values.Sum();
if (total == 0)
{
// Even split
var even = 100 / scores.Count;
return scores.ToDictionary(s => s.Key, _ => even);
}
// Proportional split
var raw = scores.ToDictionary(s => s.Key, s => (int)Math.Round(s.Value / total * 100));
// Apply floor/ceiling constraints
foreach (var key in raw.Keys.ToList())
{
raw[key] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[key]));
}
// Normalize to exactly 100%
var sum = raw.Values.Sum();
if (sum != 100 && raw.Count > 0)
{
var diff = 100 - sum;
// Add/subtract difference from the highest-scored channel
var topChannel = raw.OrderByDescending(r => r.Value).First().Key;
raw[topChannel] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[topChannel] + diff));
}
return raw;
}
// ════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════
private static double SafeDiv(double numerator, double denominator)
=> denominator > 0 ? numerator / denominator : 0;
private static string GetStrengthLabel(string channel, string objective) => channel switch
{
"google_ads" => objective switch
{
"awareness" => "Strong for search visibility",
"traffic" => "Strong for search intent clicks",
"leads" => "Strong for high-intent leads",
"sales" => "Strong for purchase intent",
_ => "Search & intent targeting"
},
"meta" => objective switch
{
"awareness" => "Strong for reach & discovery",
"traffic" => "Strong for social traffic",
"leads" => "Strong for lead gen forms",
"sales" => "Strong for retargeting & social proof",
_ => "Social reach & engagement"
},
"tiktok" => objective switch
{
"awareness" => "Strong for viral reach",
_ => "Video-first engagement"
},
_ => "Advertising channel"
};
private static ForecastRecommendation BuildRecommendation(
List<ChannelEstimate> channels, string objective)
{
if (channels.Count < 2)
return new ForecastRecommendation
{
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
Highlights = new List<string>()
};
var top = channels[0];
var second = channels[1];
var highlights = new List<string>();
// Compare key metrics
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
{
var clickRatio = top.Estimates.Clicks / second.Estimates.Clicks;
if (clickRatio > 1.3)
highlights.Add($"{ChannelDisplayName(top.Provider)}: ~{clickRatio:F0}x more clicks per dollar");
}
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
{
var impRatio = second.Estimates.Impressions / top.Estimates.Impressions;
if (impRatio > 1.5)
highlights.Add($"{ChannelDisplayName(second.Provider)}: ~{impRatio:F0}x more impressions per dollar");
}
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
{
highlights.Add($"CPA range: ${Math.Min(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0}${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
}
return new ForecastRecommendation
{
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
$"between {ChannelDisplayName(top.Provider)} and {ChannelDisplayName(second.Provider)}, " +
$"optimized for {objective}.",
Highlights = highlights
};
}
private static string ChannelDisplayName(string provider) => provider switch
{
"google_ads" => "Google",
"meta" => "Meta",
"tiktok" => "TikTok",
_ => provider
};
private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web);
/// <summary>Internal result from a single provider call</summary>
private sealed class ProviderForecastResult
{
public string Provider { get; set; } = string.Empty;
public double Impressions { get; set; }
public double? Reach { get; set; }
public double Clicks { get; set; }
public double Conversions { get; set; }
public decimal AvgCpc { get; set; }
public decimal AvgCpm { get; set; }
public double Ctr { get; set; }
public decimal? EstimatedCpa { get; set; }
public string Confidence { get; set; } = "none";
public string DataSource { get; set; } = "none";
}
}