479 lines
20 KiB
C#
479 lines
20 KiB
C#
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 0–1 (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";
|
||
}
|
||
}
|