From 292223174c2fdfeb147ce1cb1f52d1dc8dc7b3d6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 13:53:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(insights):=20anomaly=20outcome=20validator?= =?UTF-8?q?=20=E2=80=94=20hit-rate=20calibration=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a calibration dashboard that joins persisted SuspensionFlip anomalies with EventResult rows and reports whether the post-flip favourite actually won — the single metric that says whether the detector is doing its job. Domain: - AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by AnomalyDetector without re-implementing the schema. - AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved. Tennis-style two-way markets with a Draw winner are downgraded to Unresolved rather than silently counted as Miss. - AnomalySeverityThresholds: shared Low/Medium/High constants so the UI badge and the report buckets cannot drift. Application: - EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation. - AnomalyOutcomeReport carries totals, hit rate, three breakdowns (severity / sport / score bins) and a per-event title lookup so the UI needs no second pass over IEventRepository. - Score bins extend below 0.30 automatically when the operator lowers the detector threshold so the histogram total always equals ResolvedCount. UI: - Insights page at /anomalies/insights — hero header, 4-card KPI strip (hit rate tinted by tone), three breakdown grids with bar visualisation, drill-down tables for resolved and unresolved anomalies. Honors prefers-reduced-motion. RU + EN localisation. - Nav entry under Analysis section + chip button on the Anomaly Feed. Tests: +42 across Domain + Application (evaluator boundary cases including tennis two-way and Draw guard, score-bin edges, dynamic floor when threshold is lowered, event-title pass-through). All 324 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Marathon.Application/ApplicationModule.cs | 1 + .../Reporting/AnomalyOutcomeReport.cs | 60 ++ .../Reporting/OutcomeBucketKeys.cs | 23 + .../EvaluateAnomalyOutcomesUseCase.cs | 258 +++++ .../AnomalyDetection/AnomalyEvidenceData.cs | 146 +++ .../AnomalyDetection/AnomalyOutcome.cs | 51 + .../AnomalyOutcomeEvaluator.cs | 117 +++ .../AnomalySeverityThresholds.cs | 29 + src/Marathon.UI/Components/NavBody.razor | 4 + .../Pages/Anomalies/AnomalyFeed.razor | 8 + .../Pages/Anomalies/Insights.razor | 885 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 42 + .../Resources/SharedResource.ru.resx | 42 + .../Services/AnomalyInsightsService.cs | 62 ++ .../Services/AnomalyInsightsViewModels.cs | 41 + src/Marathon.UI/Services/AnomalyViewModels.cs | 11 +- .../Services/IAnomalyInsightsService.cs | 13 + .../Services/UiServicesExtensions.cs | 1 + .../EvaluateAnomalyOutcomesUseCaseTests.cs | 308 ++++++ .../AnomalyEvidenceParserTests.cs | 122 +++ .../AnomalyOutcomeEvaluatorTests.cs | 178 ++++ 21 files changed, 2398 insertions(+), 4 deletions(-) create mode 100644 src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs create mode 100644 src/Marathon.Application/Reporting/OutcomeBucketKeys.cs create mode 100644 src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/AnomalyOutcome.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/AnomalyOutcomeEvaluator.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/AnomalySeverityThresholds.cs create mode 100644 src/Marathon.UI/Pages/Anomalies/Insights.razor create mode 100644 src/Marathon.UI/Services/AnomalyInsightsService.cs create mode 100644 src/Marathon.UI/Services/AnomalyInsightsViewModels.cs create mode 100644 src/Marathon.UI/Services/IAnomalyInsightsService.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs create mode 100644 tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyEvidenceParserTests.cs create mode 100644 tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyOutcomeEvaluatorTests.cs diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index 5134a57..d266989 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -30,6 +30,7 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs b/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs new file mode 100644 index 0000000..9ac26fb --- /dev/null +++ b/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs @@ -0,0 +1,60 @@ +using Marathon.Domain.AnomalyDetection; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.Reporting; + +/// +/// Aggregate report answering the question "is the SuspensionFlip detector right?". +/// +/// Every persisted anomaly considered by this report. +/// Anomalies whose source events now have a final result. +/// Anomalies still waiting for an event result. +/// Resolved anomalies where the post-flip favourite won. +/// Resolved anomalies where the post-flip favourite lost. +/// +/// ÷ in [0, 1]. Null when no anomalies +/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data". +/// +/// Breakdown by Low / Medium / High severity buckets. +/// Breakdown by sport code. +/// Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00]. +/// All resolved anomalies, newest first. Drives the drill-down table. +/// All unresolved anomalies, newest first. +/// +/// Pre-shaped "Side1Name vs Side2Name" strings keyed by event id. Carried +/// alongside the report so UI projections do not need a second pass over +/// IEventRepository — every event in / +/// appears as a key. Missing events (e.g. pruned) are +/// absent; consumers fall back to EventId.Value. +/// +public sealed record AnomalyOutcomeReport( + int TotalAnomalies, + int ResolvedCount, + int UnresolvedCount, + int HitCount, + int MissCount, + decimal? HitRate, + IReadOnlyList BySeverity, + IReadOnlyList BySport, + IReadOnlyList ByScoreBin, + IReadOnlyList Resolved, + IReadOnlyList Unresolved, + IReadOnlyDictionary EventTitles); + +/// +/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)". +/// +/// +/// Stable, culture-invariant identifier used by the UI to localise the label +/// (e.g. "Severity.High", "Sport.22723", "Bin.0.60-0.70"). +/// +/// Resolved anomalies in this bucket. +/// Subset of where post-flip favourite won. +/// +/// ÷ , or null when is 0. +/// +public sealed record OutcomeBucket( + string Key, + int Total, + int Hits, + decimal? HitRate); diff --git a/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs b/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs new file mode 100644 index 0000000..0ff62f6 --- /dev/null +++ b/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs @@ -0,0 +1,23 @@ +namespace Marathon.Application.Reporting; + +/// +/// Canonical, culture-invariant prefixes and +/// literals. Used by the use case to emit keys and by the UI to localise them +/// — both sides reference these constants so a rename can never produce silent +/// "key not found" rendering on the page. +/// +public static class OutcomeBucketKeys +{ + /// Prefix for sport-grouped buckets, e.g. Sport.6. + public const string SportPrefix = "Sport."; + + /// Prefix for score-bin buckets, e.g. Bin.0.30-0.40. + public const string BinPrefix = "Bin."; + + /// Prefix for severity buckets, e.g. Severity.High. + public const string SeverityPrefix = "Severity."; + + public const string SeverityLow = SeverityPrefix + "Low"; + public const string SeverityMedium = SeverityPrefix + "Medium"; + public const string SeverityHigh = SeverityPrefix + "High"; +} diff --git a/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs new file mode 100644 index 0000000..e44c050 --- /dev/null +++ b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs @@ -0,0 +1,258 @@ +using System.Globalization; +using Marathon.Application.Abstractions; +using Marathon.Application.Reporting; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Entities; +using Microsoft.Extensions.Logging; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Builds an by joining every persisted +/// with the originating event and its +/// , then running the pure +/// over each pair. +/// +/// +/// +/// This is the answer to "does the SuspensionFlip detector actually predict the +/// right side?" The report is the validator for the entire anomaly-detection +/// premise of the product — without it, the algorithm's confidence score is +/// just a number with no calibration. +/// +/// +/// The use case loads all three collections in one pass each and performs the +/// join in memory. Anomaly volumes are small (one per suspension interval per +/// event) so this is well within budget. If volumes grow significantly the +/// repository layer can later add a SQL-side join — the public shape of the +/// report does not change. +/// +/// +public sealed class EvaluateAnomalyOutcomesUseCase +{ + /// + /// Lowest score bin shown in the histogram. Score values below this never + /// appear because the detector enforces a configurable threshold (default + /// 0.30) — but the constant is repeated here so the bucketer is independent + /// of any specific configuration value. + /// + public const decimal MinScore = 0.30m; + + /// + /// Bin width for the score histogram. Yields 7 buckets: + /// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80), + /// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right. + /// + public const decimal BinWidth = 0.10m; + + private readonly IAnomalyRepository _anomalies; + private readonly IEventRepository _events; + private readonly IResultRepository _results; + private readonly ILogger _logger; + + public EvaluateAnomalyOutcomesUseCase( + IAnomalyRepository anomalies, + IEventRepository events, + IResultRepository results, + ILogger logger) + { + _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _results = results ?? throw new ArgumentNullException(nameof(results)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync(CancellationToken ct) + { + _logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started"); + + var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false); + if (anomalies.Count == 0) + { + _logger.LogInformation( + "EvaluateAnomalyOutcomesUseCase: no anomalies — empty report"); + return EmptyReport(); + } + + // Build event + result lookups — distinct keys only to avoid quadratic loads. + // TODO (perf, future): batch via IEventRepository.GetManyAsync / IResultRepository.GetManyAsync + // once the repositories expose them. Today the per-event GetAsync round-trip is acceptable + // because anomaly volumes are bounded (1 row per suspension interval per event). + var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); + + var eventLookup = new Dictionary(distinctEventIds.Count); + var resultLookup = new Dictionary(distinctEventIds.Count); + var eventTitles = new Dictionary(distinctEventIds.Count); + foreach (var id in distinctEventIds) + { + ct.ThrowIfCancellationRequested(); + + var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); + if (ev is not null) + { + eventLookup[id] = ev; + eventTitles[id] = string.Concat(ev.Side1Name, " vs ", ev.Side2Name); + } + + var res = await _results.GetAsync(id, ct).ConfigureAwait(false); + if (res is not null) resultLookup[id] = res; + } + + // Evaluate every anomaly through the pure domain function. + var resolved = new List(); + var unresolved = new List(); + foreach (var anomaly in anomalies) + { + ct.ThrowIfCancellationRequested(); + + eventLookup.TryGetValue(anomaly.EventId, out var ev); + resultLookup.TryGetValue(anomaly.EventId, out var result); + + var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result); + + if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved) + unresolved.Add(evaluated); + else + resolved.Add(evaluated); + } + + var resolvedOrdered = resolved + .OrderByDescending(r => r.DetectedAt) + .ToList(); + var unresolvedOrdered = unresolved + .OrderByDescending(r => r.DetectedAt) + .ToList(); + + var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit); + var missCount = resolvedOrdered.Count - hitCount; + + var report = new AnomalyOutcomeReport( + TotalAnomalies: anomalies.Count, + ResolvedCount: resolvedOrdered.Count, + UnresolvedCount: unresolvedOrdered.Count, + HitCount: hitCount, + MissCount: missCount, + HitRate: ComputeRate(hitCount, resolvedOrdered.Count), + BySeverity: BuildSeverityBuckets(resolvedOrdered), + BySport: BuildSportBuckets(resolvedOrdered), + ByScoreBin: BuildScoreBins(resolvedOrdered), + Resolved: resolvedOrdered, + Unresolved: unresolvedOrdered, + EventTitles: eventTitles); + + _logger.LogInformation( + "EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}", + report.TotalAnomalies, report.ResolvedCount, report.HitCount); + + return report; + } + + // ── Bucketers ──────────────────────────────────────────────────────────── + + private static IReadOnlyList BuildSeverityBuckets( + IReadOnlyCollection resolved) + { + // Thresholds sourced from the Domain so the UI's severity badge and + // this report cannot drift out of sync — single source of truth. + return new[] + { + BuildBucket(OutcomeBucketKeys.SeverityLow, + resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)), + BuildBucket(OutcomeBucketKeys.SeverityMedium, + resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium + && r.Score < AnomalySeverityThresholds.High)), + BuildBucket(OutcomeBucketKeys.SeverityHigh, + resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)), + }; + } + + private static IReadOnlyList BuildSportBuckets( + IReadOnlyCollection resolved) + { + return resolved + .Where(r => r.Sport is not null) + .GroupBy(r => r.Sport!.Value) + .OrderBy(g => g.Key) + .Select(g => BuildBucket( + key: string.Format( + CultureInfo.InvariantCulture, + "{0}{1}", + OutcomeBucketKeys.SportPrefix, + g.Key), + items: g)) + .ToList(); + } + + private static IReadOnlyList BuildScoreBins( + IReadOnlyCollection resolved) + { + // Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins. + // If the operator has lowered the detector's flip threshold and we have + // resolved anomalies below 0.30, prepend additional bins so every row in + // the report shows up in exactly one bucket — the histogram total must + // equal ResolvedCount no matter how the detector is tuned. + var floor = MinScore; + if (resolved.Count > 0) + { + var lowest = resolved.Min(r => r.Score); + if (lowest < MinScore) + { + var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth); + floor = MinScore - binsBelow * BinWidth; + if (floor < 0m) floor = 0m; + } + } + + var bins = new List(); + for (var start = floor; start < 1.0m; start += BinWidth) + { + var binStart = start; + var binEnd = start + BinWidth; + var isLast = binEnd >= 1.0m; + + // Last bin is closed on the right so 1.00 lands in [0.90, 1.00]. + var inBin = resolved.Where(r => + r.Score >= binStart && + (isLast ? r.Score <= 1.0m : r.Score < binEnd)); + + var key = string.Format( + CultureInfo.InvariantCulture, + "{0}{1:0.00}-{2:0.00}", + OutcomeBucketKeys.BinPrefix, + binStart, + Math.Min(binEnd, 1.0m)); + + bins.Add(BuildBucket(key, inBin)); + } + return bins; + } + + private static OutcomeBucket BuildBucket(string key, IEnumerable items) + { + var list = items as IReadOnlyCollection ?? items.ToList(); + var total = list.Count; + var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit); + return new OutcomeBucket(key, total, hits, ComputeRate(hits, total)); + } + + private static decimal? ComputeRate(int numerator, int denominator) => + denominator == 0 + ? null + : Math.Round(numerator / (decimal)denominator, 4); + + private static AnomalyOutcomeReport EmptyReport() => + new( + TotalAnomalies: 0, + ResolvedCount: 0, + UnresolvedCount: 0, + HitCount: 0, + MissCount: 0, + HitRate: null, + BySeverity: Array.Empty(), + BySport: Array.Empty(), + ByScoreBin: Array.Empty(), + Resolved: Array.Empty(), + Unresolved: Array.Empty(), + EventTitles: new Dictionary()); +} diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs b/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs new file mode 100644 index 0000000..f02b2ab --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Strongly typed projection of the JSON payload written by +/// into . Captures pre- and post-suspension snapshots +/// of normalised implied probabilities and raw rates for the Match-Win market. +/// +/// +/// The evaluator and any reader that needs to inspect an anomaly's evidence should +/// parse via rather than re-implement +/// the JSON shape — the detector owns the schema. +/// +public sealed record AnomalyEvidenceData( + int SuspensionGapSeconds, + AnomalyEvidenceSide PreSuspension, + AnomalyEvidenceSide PostSuspension); + +/// +/// One side (pre or post) of a suspension interval. Probabilities are normalised +/// so that P1 + (PDraw ?? 0) + P2 == 1. Two-way markets (e.g. tennis) +/// leave and null. +/// +public sealed record AnomalyEvidenceSide( + DateTimeOffset CapturedAt, + decimal P1, + decimal? PDraw, + decimal P2, + decimal Rate1, + decimal? RateDraw, + decimal Rate2) +{ + /// + /// The side carrying the highest normalised implied probability — i.e., + /// the bookmaker's favourite at this point in time. + /// + public Side Favourite + { + get + { + // Three-way: include Draw in the argmax. + var best = Side.Side1; + var bestValue = P1; + if (PDraw is { } pd && pd > bestValue) + { + best = Side.Draw; + bestValue = pd; + } + if (P2 > bestValue) + { + best = Side.Side2; + } + return best; + } + } +} + +/// +/// Parses the string emitted by +/// . Tolerant of malformed payloads — returns false +/// rather than throwing so callers can skip un-parseable anomalies silently. +/// +public static class AnomalyEvidenceParser +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// + /// Attempts to deserialise the evidence JSON. Returns true only when + /// both pre- and post-suspension snapshots are present. + /// + public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data) + { + data = default!; + if (string.IsNullOrWhiteSpace(evidenceJson)) return false; + + try + { + var dto = JsonSerializer.Deserialize(evidenceJson, JsonOptions); + if (dto is null || dto.PreSuspension is null || dto.PostSuspension is null) + return false; + + data = new AnomalyEvidenceData( + SuspensionGapSeconds: dto.SuspensionGapSeconds, + PreSuspension: ToSide(dto.PreSuspension), + PostSuspension: ToSide(dto.PostSuspension)); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static AnomalyEvidenceSide ToSide(EvidenceSideDto dto) => + new( + CapturedAt: dto.CapturedAt, + P1: dto.P1 ?? 0m, + PDraw: dto.PDraw, + P2: dto.P2 ?? 0m, + Rate1: dto.Rate1 ?? 0m, + RateDraw: dto.RateDraw, + Rate2: dto.Rate2 ?? 0m); + + private sealed class EvidenceDto + { + [JsonPropertyName("suspensionGapSeconds")] + public int SuspensionGapSeconds { get; set; } + + [JsonPropertyName("preSuspension")] + public EvidenceSideDto? PreSuspension { get; set; } + + [JsonPropertyName("postSuspension")] + public EvidenceSideDto? PostSuspension { get; set; } + } + + private sealed class EvidenceSideDto + { + [JsonPropertyName("capturedAt")] + public DateTimeOffset CapturedAt { get; set; } + + [JsonPropertyName("p1")] + public decimal? P1 { get; set; } + + [JsonPropertyName("pDraw")] + public decimal? PDraw { get; set; } + + [JsonPropertyName("p2")] + public decimal? P2 { get; set; } + + [JsonPropertyName("rate1")] + public decimal? Rate1 { get; set; } + + [JsonPropertyName("rateDraw")] + public decimal? RateDraw { get; set; } + + [JsonPropertyName("rate2")] + public decimal? Rate2 { get; set; } + } +} diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalyOutcome.cs b/src/Marathon.Domain/AnomalyDetection/AnomalyOutcome.cs new file mode 100644 index 0000000..a38e993 --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/AnomalyOutcome.cs @@ -0,0 +1,51 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Verdict produced by comparing an anomaly's predicted post-flip favourite +/// against the actual . +/// +public enum AnomalyOutcomeKind +{ + /// + /// The post-flip favourite (the side the bookmaker shortened odds on AFTER + /// the suspension) ended up winning. The flip was directionally correct. + /// + Hit, + + /// + /// The post-flip favourite did NOT win. The flip pointed at the wrong side. + /// + Miss, + + /// + /// No is available yet — outcome cannot be judged. + /// + Unresolved, +} + +/// +/// One anomaly paired with its evaluated outcome. Surfaced to the UI so each +/// resolved anomaly can be reviewed individually (e.g., when investigating +/// why the algorithm got a specific event wrong). +/// +/// +/// and are null +/// when the anomaly's evidence JSON could not be parsed — the outcome will be +/// in that case. Encoding the +/// absence keeps consumers from being shown a fabricated side. +/// +public sealed record ResolvedAnomaly( + Guid AnomalyId, + EventId EventId, + DateTimeOffset DetectedAt, + decimal Score, + AnomalyKind Kind, + SportCode? Sport, + Side? PreFlipFavourite, + Side? PostFlipFavourite, + Side? ActualWinner, + AnomalyOutcomeKind Outcome); diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalyOutcomeEvaluator.cs b/src/Marathon.Domain/AnomalyDetection/AnomalyOutcomeEvaluator.cs new file mode 100644 index 0000000..2678562 --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/AnomalyOutcomeEvaluator.cs @@ -0,0 +1,117 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Pure domain function that evaluates whether a +/// anomaly's prediction (the post-suspension favourite) matched the actual +/// . +/// +/// +/// +/// A "hit" is recorded when the side carrying the highest implied probability +/// in equals +/// . For two-way markets (tennis), Draw is +/// not a possible favourite — the evaluator naturally never emits Draw there. +/// +/// +/// Stateless, deterministic, no I/O. Safe to call in tight loops. +/// +/// +public static class AnomalyOutcomeEvaluator +{ + /// + /// Evaluates one anomaly against its event (optional metadata) and its result + /// (optional — null when the match hasn't been graded yet). + /// + /// The persisted anomaly. + /// + /// The event's sport — surfaced into so the UI + /// can group by sport. Null when the originating event row is missing. + /// + /// The event's final result, if known. + /// + /// A with + /// when is null or the evidence JSON cannot be parsed, + /// otherwise / . + /// + public static ResolvedAnomaly Evaluate( + Anomaly anomaly, + SportCode? sport, + EventResult? result) + { + ArgumentNullException.ThrowIfNull(anomaly); + + if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data)) + { + // Cannot determine favourite without evidence; treat as unresolved. + return new ResolvedAnomaly( + AnomalyId: anomaly.Id, + EventId: anomaly.EventId, + DetectedAt: anomaly.DetectedAt, + Score: anomaly.Score, + Kind: anomaly.Kind, + Sport: sport, + PreFlipFavourite: null, + PostFlipFavourite: null, + ActualWinner: result?.WinnerSide, + Outcome: AnomalyOutcomeKind.Unresolved); + } + + var preFav = data.PreSuspension.Favourite; + var postFav = data.PostSuspension.Favourite; + + if (result is null) + { + return new ResolvedAnomaly( + AnomalyId: anomaly.Id, + EventId: anomaly.EventId, + DetectedAt: anomaly.DetectedAt, + Score: anomaly.Score, + Kind: anomaly.Kind, + Sport: sport, + PreFlipFavourite: preFav, + PostFlipFavourite: postFav, + ActualWinner: null, + Outcome: AnomalyOutcomeKind.Unresolved); + } + + // Guard rail for sport-specific impossibilities. A two-way market + // (e.g. tennis) cannot produce a Draw outcome — if one shows up the + // EventResult disagrees with the evidence schema, so we refuse to + // grade it instead of silently counting it as a Miss. + var isTwoWay = data.PreSuspension.PDraw is null && data.PostSuspension.PDraw is null; + if (isTwoWay && result.WinnerSide == Side.Draw) + { + return new ResolvedAnomaly( + AnomalyId: anomaly.Id, + EventId: anomaly.EventId, + DetectedAt: anomaly.DetectedAt, + Score: anomaly.Score, + Kind: anomaly.Kind, + Sport: sport, + PreFlipFavourite: preFav, + PostFlipFavourite: postFav, + ActualWinner: result.WinnerSide, + Outcome: AnomalyOutcomeKind.Unresolved); + } + + var outcome = postFav == result.WinnerSide + ? AnomalyOutcomeKind.Hit + : AnomalyOutcomeKind.Miss; + + return new ResolvedAnomaly( + AnomalyId: anomaly.Id, + EventId: anomaly.EventId, + DetectedAt: anomaly.DetectedAt, + Score: anomaly.Score, + Kind: anomaly.Kind, + Sport: sport, + PreFlipFavourite: preFav, + PostFlipFavourite: postFav, + ActualWinner: result.WinnerSide, + Outcome: outcome); + } +} diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalySeverityThresholds.cs b/src/Marathon.Domain/AnomalyDetection/AnomalySeverityThresholds.cs new file mode 100644 index 0000000..85bfbad --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/AnomalySeverityThresholds.cs @@ -0,0 +1,29 @@ +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Single source of truth for the severity bucket boundaries that the UI +/// pill / badge, the Insights breakdowns, and any future reporter share. +/// +/// +/// Buckets are inclusive on the left, exclusive on the right (except High +/// which extends to 1.00 inclusive): +/// +/// Low [, ) +/// Medium [, ) +/// High [, 1.00] +/// +/// Defined at the Domain layer so both the Application reporter and the +/// Marathon.UI severity rules consume the same numbers — re-tuning happens +/// in one place. +/// +public static class AnomalySeverityThresholds +{ + /// Lower bound of the Low bucket. Matches the detector's default flip threshold. + public const decimal Low = 0.30m; + + /// Lower bound of the Medium bucket. + public const decimal Medium = 0.45m; + + /// Lower bound of the High bucket. + public const decimal High = 0.60m; +} diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index af1c432..6c31d2f 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -39,6 +39,10 @@ @L["Nav.Results"] + + + @L["Nav.Insights"] + diff --git a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor index 0292305..7a79bdd 100644 --- a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor +++ b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor @@ -98,6 +98,9 @@ + @@ -269,6 +272,11 @@ State.MarkAllSeen(DateTimeOffset.UtcNow); } + private void OpenInsights() + { + Nav.NavigateTo("/anomalies/insights"); + } + private void HandleClick(AnomalyListItem item) { Nav.NavigateTo($"/anomalies/{item.Id}"); diff --git a/src/Marathon.UI/Pages/Anomalies/Insights.razor b/src/Marathon.UI/Pages/Anomalies/Insights.razor new file mode 100644 index 0000000..45de595 --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/Insights.razor @@ -0,0 +1,885 @@ +@* + Insights — calibration page for the SuspensionFlip detector. + + Loads a precomputed AnomalyInsightsVm and answers the single question that + matters: when the bookmaker flipped, did the post-flip favourite actually + win? Big numbers up top, three breakdowns in the middle, drill-down tables + at the bottom. Same editorial-quant tone as AnomalyFeed / Home. +*@ + +@page "/anomalies/insights" +@using Marathon.Application.Reporting +@using Marathon.Domain.AnomalyDetection +@using Marathon.Domain.Enums +@implements IDisposable +@inject IStringLocalizer L +@inject IAnomalyInsightsService InsightsService +@inject NavigationManager Nav + +@L["App.Title"] · @L["Nav.Insights"] + +
+
+
+ + @L["Insights.Kicker"] + +

@L["Insights.Title"]

+

@L["Insights.Lede"]

+
+
+ +
+
+ + @if (_loading && _vm is null) + { +
+ + @L["Common.Loading"] +
+ } + else if (_errored && _vm is null) + { +
+ + @L["Common.Empty"] + +

+ @L["Insights.Empty.None"] +

+
+ } + else if (_vm is { } vm) + { + @* ---------- KPI strip ---------- *@ +
+
+ @L["Insights.Stat.HitRate"] + @FormatPercent(vm.HitRate) + @L["Insights.Stat.HitRate.Hint"] +
+
+ @L["Insights.Stat.Resolved"] + + @vm.ResolvedCount / @vm.TotalAnomalies + + @L["Insights.Stat.Resolved.Hint"] +
+
+ @L["Insights.Stat.Unresolved"] + @vm.UnresolvedCount + @L["Insights.Stat.Unresolved.Hint"] +
+
+
+
+ @L["Insights.Stat.Hits"] + @vm.HitCount +
+ +
+ @L["Insights.Stat.Misses"] + @vm.MissCount +
+
+
+
+ +
+ + @* ---------- By severity ---------- *@ +
+
+ @L["Insights.Section.BySeverity"] +
+ @RenderBucketTable(vm.BySeverity, BucketRenderKind.Severity) +
+ +
+ + @* ---------- By sport ---------- *@ +
+
+ @L["Insights.Section.BySport"] +
+ @RenderBucketTable(vm.BySport, BucketRenderKind.Sport) +
+ +
+ + @* ---------- By score bin (7 fixed rows) ---------- *@ +
+
+ @L["Insights.Section.ByScore"] +
+ @RenderBucketTable(vm.ByScoreBin, BucketRenderKind.Score) +
+ +
+ + @* ---------- Resolved table ---------- *@ +
+
+ @L["Insights.Section.Resolved"] + @vm.Resolved.Count +
+ + @if (vm.TotalAnomalies == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Insights.Empty.None"] +

+
+ } + else if (vm.Resolved.Count == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Insights.Empty.NoneResolved"] +

+
+ } + else + { +
+ + + + + + + + + + + + + + + + @foreach (var row in vm.Resolved) + { + var local = row; + + + + + + + + + + + + } + +
@L["Insights.Column.DetectedAt"]@L["Insights.Column.Match"]@L["Insights.Column.Sport"]@L["Insights.Column.Score"]@L["Insights.Column.PreFavourite"]@L["Insights.Column.PostFavourite"]@L["Insights.Column.Winner"]@L["Insights.Column.Outcome"]
@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)@local.EventTitle + @if (local.Sport is { } sport) + { + + + @SportLabels.Resolve(L, sport.Value) + + } + else + { + + } + + @local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + @SideLabel(local.PreFlipFavourite)@SideLabel(local.PostFlipFavourite)@SideLabel(local.ActualWinner) + + @OutcomeLabel(local.Outcome) + + + + @L["Insights.Action.OpenAnomaly"] + + +
+
+ } +
+ + @* ---------- Unresolved table (only when non-empty) ---------- *@ + @if (vm.Unresolved.Count > 0) + { +
+ +
+
+ + @L["Insights.Section.Unresolved"] + + @vm.Unresolved.Count +
+ +
+ + + + + + + + + + + + + + + @foreach (var row in vm.Unresolved) + { + var local = row; + + + + + + + + + + + } + +
@L["Insights.Column.DetectedAt"]@L["Insights.Column.Match"]@L["Insights.Column.Sport"]@L["Insights.Column.Score"]@L["Insights.Column.PreFavourite"]@L["Insights.Column.PostFavourite"]@L["Insights.Column.Outcome"]
@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)@local.EventTitle + @if (local.Sport is { } sport) + { + + + @SportLabels.Resolve(L, sport.Value) + + } + else + { + + } + + @local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + @SideLabel(local.PreFlipFavourite)@SideLabel(local.PostFlipFavourite) + + @L["Insights.Outcome.Unresolved"] + + + + @L["Insights.Action.OpenAnomaly"] + + +
+
+
+ } + } +
+ + + +@code { + // Render kind for the breakdown grid — disambiguates how `Key` is shown. + private enum BucketRenderKind + { + Severity, + Sport, + Score, + } + + private AnomalyInsightsVm? _vm; + private bool _loading = true; + private bool _errored; + private CancellationTokenSource? _loadCts; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loadCts?.Cancel(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + + _loading = true; + _errored = false; + StateHasChanged(); + + try + { + var report = await InsightsService.GetReportAsync(ct); + if (ct.IsCancellationRequested) return; + _vm = report; + } + catch (OperationCanceledException) { /* superseded */ } + catch + { + _errored = true; + _vm = null; + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private void OpenAnomaly(MouseEventArgs e, Guid anomalyId) + { + Nav.NavigateTo("/anomalies/" + anomalyId.ToString()); + } + + // ---- Bucket rendering --------------------------------------------------- + + private RenderFragment RenderBucketTable( + IReadOnlyList buckets, + BucketRenderKind kind) => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "m-insights__buckets"); + builder.AddAttribute(2, "data-test", "insights-bucket-grid"); + + // Head row + builder.OpenElement(10, "div"); + builder.AddAttribute(11, "class", "m-insights__bucket-head"); + + builder.OpenElement(12, "span"); + builder.AddContent(13, L["Insights.Column.Bucket"]); + builder.CloseElement(); + + builder.OpenElement(14, "span"); + builder.AddContent(15, L["Insights.Column.HitsOfTotal"]); + builder.CloseElement(); + + builder.OpenElement(16, "span"); + builder.AddContent(17, L["Insights.Column.HitRate"]); + builder.CloseElement(); + + builder.CloseElement(); + + // Data rows + var seq = 100; + foreach (var bucket in buckets) + { + var local = bucket; + var isEmpty = local.Total == 0; + var rowClass = isEmpty + ? "m-insights__bucket-row m-insights__bucket-row--dim" + : "m-insights__bucket-row"; + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", rowClass); + builder.AddAttribute(seq++, "data-test", "insights-bucket-row"); + builder.AddAttribute(seq++, "data-bucket-key", local.Key); + + // Label cell + builder.OpenElement(seq++, "span"); + var labelClass = kind == BucketRenderKind.Score + ? "m-insights__bucket-label m-insights__bucket-label--mono" + : "m-insights__bucket-label"; + builder.AddAttribute(seq++, "class", labelClass); + builder.AddContent(seq++, RenderBucketLabel(local.Key, kind)); + builder.CloseElement(); + + // Counts cell + builder.OpenElement(seq++, "span"); + builder.AddAttribute(seq++, "class", "m-insights__bucket-counts"); + builder.OpenElement(seq++, "strong"); + builder.AddContent(seq++, local.Hits.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.CloseElement(); + builder.AddContent(seq++, " / " + local.Total.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.CloseElement(); + + // Hit-rate cell — bar + percent, or N/A pill when empty + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "m-insights__bar"); + + if (isEmpty || local.HitRate is null) + { + builder.OpenElement(seq++, "span"); + builder.AddAttribute(seq++, "class", "m-insights__bar-na"); + builder.AddContent(seq++, L["Insights.Bucket.NotApplicable"]); + builder.CloseElement(); + + builder.OpenElement(seq++, "span"); + builder.AddAttribute(seq++, "class", "m-insights__bar-pct"); + builder.AddAttribute(seq++, "style", "color: var(--m-c-ink-soft);"); + builder.AddContent(seq++, "—"); + builder.CloseElement(); + } + else + { + var rate = local.HitRate.Value; + var pct = (double)(rate * 100m); + var pctClamped = Math.Max(0, Math.Min(100, pct)); + var tone = rate >= 0.60m ? "positive" : (rate < 0.40m ? "negative" : "neutral"); + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "m-insights__bar-track"); + builder.AddAttribute(seq++, "role", "progressbar"); + builder.AddAttribute(seq++, "aria-valuemin", "0"); + builder.AddAttribute(seq++, "aria-valuemax", "100"); + builder.AddAttribute(seq++, "aria-valuenow", pctClamped.ToString("0", System.Globalization.CultureInfo.InvariantCulture)); + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "m-insights__bar-fill m-insights__bar-fill--" + tone); + builder.AddAttribute(seq++, "style", "width: " + pctClamped.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "%;"); + builder.CloseElement(); + + builder.CloseElement(); + + builder.OpenElement(seq++, "span"); + builder.AddAttribute(seq++, "class", "m-insights__bar-pct"); + builder.AddContent(seq++, ((int)Math.Round(pct, MidpointRounding.AwayFromZero)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%"); + builder.CloseElement(); + } + + builder.CloseElement(); // .m-insights__bar + builder.CloseElement(); // .m-insights__bucket-row + } + + builder.CloseElement(); // .m-insights__buckets + }; + + private RenderFragment RenderBucketLabel(string key, BucketRenderKind kind) => builder => + { + switch (kind) + { + case BucketRenderKind.Severity: + { + var locKey = key switch + { + OutcomeBucketKeys.SeverityHigh => "Anomaly.Severity.High", + OutcomeBucketKeys.SeverityMedium => "Anomaly.Severity.Medium", + OutcomeBucketKeys.SeverityLow => "Anomaly.Severity.Low", + _ => "Anomaly.Severity.Low", + }; + builder.AddContent(0, L[locKey]); + break; + } + case BucketRenderKind.Sport: + { + var trimmed = key.StartsWith(OutcomeBucketKeys.SportPrefix, StringComparison.Ordinal) + ? key.Substring(OutcomeBucketKeys.SportPrefix.Length) + : key; + if (int.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var code)) + { + var label = SportLabels.Resolve(L, code); + builder.OpenComponent(0); + builder.AddAttribute(1, "Code", code); + builder.AddAttribute(2, "Label", label); + builder.AddAttribute(3, "ClassName", "m-insights__sport-icon"); + builder.CloseComponent(); + builder.AddContent(4, label); + } + else + { + builder.AddContent(0, key); + } + break; + } + case BucketRenderKind.Score: + default: + { + var trimmed = key.StartsWith(OutcomeBucketKeys.BinPrefix, StringComparison.Ordinal) + ? key.Substring(OutcomeBucketKeys.BinPrefix.Length) + : key; + builder.AddContent(0, trimmed); + break; + } + } + }; + + // ---- Formatting / labels ----------------------------------------------- + + private static string HitRateTone(decimal? rate) => rate switch + { + null => "neutral", + >= 0.60m => "positive", + < 0.40m => "negative", + _ => "neutral", + }; + + private static string FormatPercent(decimal? rate) + { + if (rate is null) return "—"; + var pct = (int)Math.Round(rate.Value * 100m, MidpointRounding.AwayFromZero); + return pct.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%"; + } + + private string SideLabel(Side? side) => side switch + { + Side.Side1 => L["Insights.Side.Side1"], + Side.Side2 => L["Insights.Side.Side2"], + Side.Draw => L["Insights.Side.Draw"], + _ => L["Insights.Side.Unknown"], + }; + + private string OutcomeLabel(AnomalyOutcomeKind o) => o switch + { + AnomalyOutcomeKind.Hit => L["Insights.Outcome.Hit"], + AnomalyOutcomeKind.Miss => L["Insights.Outcome.Miss"], + AnomalyOutcomeKind.Unresolved => L["Insights.Outcome.Unresolved"], + _ => L["Insights.Outcome.Unresolved"], + }; + + private static string OutcomeCss(AnomalyOutcomeKind o) => o switch + { + AnomalyOutcomeKind.Hit => "hit", + AnomalyOutcomeKind.Miss => "miss", + AnomalyOutcomeKind.Unresolved => "pending", + _ => "pending", + }; + + public void Dispose() + { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 1d5f2c8..67b4899 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -305,4 +305,46 @@ Failed Loaded {0}, skipped {1}, processed {2} total. No events to load in this range. + + Insights + Calibration + Did the flips predict the winner? + Every persisted suspension-flip anomaly joined against the final event result. The hit rate tells you whether the post-flip favourite is the side that actually won — the only metric that says the detector is doing its job. + Hit rate + Post-flip favourite won. + Resolved + Anomalies with a graded event. + Unresolved + Awaiting event result. + Hits + Misses + Total anomalies + By severity + By sport + By confidence score + Resolved anomalies + Awaiting results + Detected + Match + Sport + Score + Pre-flip pick + Post-flip pick + Actual winner + Verdict + Bucket + Hit rate + Hits / total + Hit + Miss + Pending + Side 1 + Side 2 + Draw + + No anomalies have been recorded yet. Once the detector flags one and the matching event finishes, its verdict will appear here. + Anomalies exist but no matching events have been graded yet. Run the results loader or wait for matches to complete. + Refresh + Open + diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index b9d78fa..f489384 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -318,4 +318,46 @@ Ошибка Загружено {0}, пропущено {1}, всего обработано {2}. Нет событий для загрузки в этом диапазоне. + + Калибровка + Калибровка + Угадывают ли флипы победителя? + Каждая зафиксированная аномалия suspension-flip сопоставлена с итогом матча. Hit rate показывает, оказался ли пост-флип фаворит реальным победителем — это и есть единственная метрика, говорящая, что детектор работает. + Hit rate + Пост-флип фаворит выиграл. + Подтверждены + Аномалии с известным итогом. + Без результата + Ждём окончания матча. + Попадания + Промахи + Всего аномалий + По уровню + По виду спорта + По уверенности + Подтверждённые аномалии + Ожидают итога + Замечено + Матч + Вид спорта + Score + До флипа + После флипа + Победитель + Вердикт + Группа + Hit rate + Попаданий / всего + Попадание + Промах + Ожидает + Сторона 1 + Сторона 2 + Ничья + + Аномалии ещё не зафиксированы. Когда детектор отметит первую и матч завершится, его вердикт появится здесь. + Аномалии есть, но ни у одного из их событий нет результата. Запустите загрузчик результатов или подождите окончания матчей. + Обновить + Открыть + diff --git a/src/Marathon.UI/Services/AnomalyInsightsService.cs b/src/Marathon.UI/Services/AnomalyInsightsService.cs new file mode 100644 index 0000000..933f507 --- /dev/null +++ b/src/Marathon.UI/Services/AnomalyInsightsService.cs @@ -0,0 +1,62 @@ +using Marathon.Application.UseCases; +using Marathon.Domain.AnomalyDetection; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Services; + +/// +/// Page-facing implementation of . Runs +/// the application use case and reshapes its output for the page — event title +/// strings and severity buckets are computed once from the report's payload, so +/// the service performs no repository I/O of its own. +/// +public sealed class AnomalyInsightsService : IAnomalyInsightsService +{ + private readonly EvaluateAnomalyOutcomesUseCase _useCase; + + public AnomalyInsightsService(EvaluateAnomalyOutcomesUseCase useCase) + { + _useCase = useCase ?? throw new ArgumentNullException(nameof(useCase)); + } + + public async Task GetReportAsync(CancellationToken ct) + { + var report = await _useCase.ExecuteAsync(ct).ConfigureAwait(false); + + var resolvedRows = report.Resolved + .Select(r => ToRow(r, report.EventTitles)) + .ToList(); + var unresolvedRows = report.Unresolved + .Select(r => ToRow(r, report.EventTitles)) + .ToList(); + + return new AnomalyInsightsVm( + TotalAnomalies: report.TotalAnomalies, + ResolvedCount: report.ResolvedCount, + UnresolvedCount: report.UnresolvedCount, + HitCount: report.HitCount, + MissCount: report.MissCount, + HitRate: report.HitRate, + BySeverity: report.BySeverity, + BySport: report.BySport, + ByScoreBin: report.ByScoreBin, + Resolved: resolvedRows, + Unresolved: unresolvedRows); + } + + private static ResolvedAnomalyRow ToRow( + ResolvedAnomaly src, + IReadOnlyDictionary titles) => + new( + AnomalyId: src.AnomalyId, + EventId: src.EventId, + EventTitle: titles.TryGetValue(src.EventId, out var t) ? t : src.EventId.Value, + DetectedAt: src.DetectedAt, + Score: src.Score, + Severity: AnomalySeverityRules.FromScore(src.Score), + Sport: src.Sport, + PreFlipFavourite: src.PreFlipFavourite, + PostFlipFavourite: src.PostFlipFavourite, + ActualWinner: src.ActualWinner, + Outcome: src.Outcome); +} diff --git a/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs b/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs new file mode 100644 index 0000000..004b19b --- /dev/null +++ b/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs @@ -0,0 +1,41 @@ +using Marathon.Application.Reporting; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.UI.Services; + +/// +/// UI-facing projection of . Adds a resolved +/// event title and severity bucket per row so the page never has to round-trip +/// to a repository. +/// +public sealed record AnomalyInsightsVm( + int TotalAnomalies, + int ResolvedCount, + int UnresolvedCount, + int HitCount, + int MissCount, + decimal? HitRate, + IReadOnlyList BySeverity, + IReadOnlyList BySport, + IReadOnlyList ByScoreBin, + IReadOnlyList Resolved, + IReadOnlyList Unresolved); + +/// +/// One row in the resolved / unresolved drill-down list — anomaly + outcome + +/// pre-shaped event title for the link-back affordance. +/// +public sealed record ResolvedAnomalyRow( + Guid AnomalyId, + EventId EventId, + string EventTitle, + DateTimeOffset DetectedAt, + decimal Score, + AnomalySeverity Severity, + SportCode? Sport, + Side? PreFlipFavourite, + Side? PostFlipFavourite, + Side? ActualWinner, + AnomalyOutcomeKind Outcome); diff --git a/src/Marathon.UI/Services/AnomalyViewModels.cs b/src/Marathon.UI/Services/AnomalyViewModels.cs index a3188f0..6d0e24f 100644 --- a/src/Marathon.UI/Services/AnomalyViewModels.cs +++ b/src/Marathon.UI/Services/AnomalyViewModels.cs @@ -1,3 +1,4 @@ +using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; @@ -83,12 +84,14 @@ public enum AnomalyFavourite None, } -/// Helpers for severity bucketing. +/// Helpers for severity bucketing. Thresholds come from +/// so the UI badges and the +/// Application-layer outcome report agree by construction. public static class AnomalySeverityRules { - public const decimal LowThreshold = 0.30m; - public const decimal MediumThreshold = 0.45m; - public const decimal HighThreshold = 0.60m; + public const decimal LowThreshold = AnomalySeverityThresholds.Low; + public const decimal MediumThreshold = AnomalySeverityThresholds.Medium; + public const decimal HighThreshold = AnomalySeverityThresholds.High; public static AnomalySeverity FromScore(decimal score) => score switch { diff --git a/src/Marathon.UI/Services/IAnomalyInsightsService.cs b/src/Marathon.UI/Services/IAnomalyInsightsService.cs new file mode 100644 index 0000000..a6a3ef5 --- /dev/null +++ b/src/Marathon.UI/Services/IAnomalyInsightsService.cs @@ -0,0 +1,13 @@ +namespace Marathon.UI.Services; + +/// +/// Browsing facade in front of . +/// The Insights page binds to this — never to the use case directly — so the +/// per-row event-title join, severity bucketing, and any future caching live +/// in one place. +/// +public interface IAnomalyInsightsService +{ + /// Builds the full report and projects it for the UI. + Task GetReportAsync(CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 160d69f..6ddb3e1 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -57,6 +57,7 @@ public static class UiServicesExtensions // Browsing facades — Scoped so they capture the per-circuit repository scope. services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Settings writer — file path is host-resolved. diff --git a/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs new file mode 100644 index 0000000..17bd833 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs @@ -0,0 +1,308 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.Reporting; +using Marathon.Application.UseCases; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +/// +/// Unit tests for covering empty +/// state, mixed hit/miss aggregation, unresolved partitioning, and missing +/// event metadata fallbacks. +/// +public sealed class EvaluateAnomalyOutcomesUseCaseTests +{ + private readonly IAnomalyRepository _anomalies = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset BaseTime = + new(2026, 5, 10, 18, 0, 0, MoscowOffset); + + // Flip evidence with Side1 → Side2 reversal. + private const string FlipEvidence = """ + { + "suspensionGapSeconds": 90, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.55, "pDraw": 0.20, "p2": 0.25, + "rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:02:30+03:00", + "p1": 0.25, "pDraw": 0.20, "p2": 0.55, + "rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8 + } + } + """; + + private EvaluateAnomalyOutcomesUseCase CreateSut() => + new(_anomalies, _events, _results, + NullLogger.Instance); + + private static Anomaly MakeAnomaly(EventId eventId, decimal score) => + new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip, + score, FlipEvidence); + + private static Event MakeEvent(EventId id, int sportCode) => + new(id, new SportCode(sportCode), "BY", "L1", "Cat", + BaseTime, "Team A", "Team B"); + + [Fact] + public async Task Should_ReturnEmptyReport_When_NoAnomaliesExist() + { + _anomalies.ListAsync(Arg.Any()) + .Returns(Array.Empty().ToList().AsReadOnly()); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.TotalAnomalies.Should().Be(0); + report.HitRate.Should().BeNull(); + report.Resolved.Should().BeEmpty(); + report.BySport.Should().BeEmpty(); + report.BySeverity.Should().BeEmpty(); + report.ByScoreBin.Should().BeEmpty(); + } + + [Fact] + public async Task Should_PartitionAnomalies_Into_ResolvedAndUnresolved() + { + var id1 = new EventId("11111111"); + var id2 = new EventId("22222222"); + + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeAnomaly(id1, score: 0.65m), + MakeAnomaly(id2, score: 0.40m), + }.ToList().AsReadOnly()); + + _events.GetAsync(id1, Arg.Any()).Returns(MakeEvent(id1, 11)); + _events.GetAsync(id2, Arg.Any()).Returns(MakeEvent(id2, 6)); + + // id1 has a result → resolved; id2 has no result → unresolved. + _results.GetAsync(id1, Arg.Any()) + .Returns(new EventResult(id1, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(id2, Arg.Any()) + .Returns((EventResult?)null); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.TotalAnomalies.Should().Be(2); + report.ResolvedCount.Should().Be(1); + report.UnresolvedCount.Should().Be(1); + report.HitCount.Should().Be(1, "id1's post-flip favourite (Side2) matched the actual winner"); + report.MissCount.Should().Be(0); + report.HitRate.Should().Be(1.0m); + } + + [Fact] + public async Task Should_ComputeHitRate_Across_MixedHitsAndMisses() + { + var ids = Enumerable.Range(1, 4) + .Select(i => new EventId($"event-{i:00000000}")) + .ToArray(); + + _anomalies.ListAsync(Arg.Any()) + .Returns(ids.Select(id => MakeAnomaly(id, score: 0.55m)).ToList().AsReadOnly()); + + foreach (var id in ids) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + } + + // Three hits (Side2 wins), one miss (Side1 wins). + _results.GetAsync(ids[0], Arg.Any()) + .Returns(new EventResult(ids[0], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(ids[1], Arg.Any()) + .Returns(new EventResult(ids[1], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(ids[2], Arg.Any()) + .Returns(new EventResult(ids[2], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(ids[3], Arg.Any()) + .Returns(new EventResult(ids[3], 2, 0, Side.Side1, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.HitCount.Should().Be(3); + report.MissCount.Should().Be(1); + report.HitRate.Should().Be(0.75m); + } + + [Fact] + public async Task Should_BuildSeverityBuckets_Across_LowMediumHigh() + { + var idLow = new EventId("low000000"); + var idMed = new EventId("med000000"); + var idHigh = new EventId("high00000"); + + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeAnomaly(idLow, score: 0.35m), + MakeAnomaly(idMed, score: 0.50m), + MakeAnomaly(idHigh, score: 0.75m), + }.ToList().AsReadOnly()); + + foreach (var id in new[] { idLow, idMed, idHigh }) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.BySeverity.Should().HaveCount(3); + report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityLow).Total.Should().Be(1); + report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityMedium).Total.Should().Be(1); + report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityHigh).Total.Should().Be(1); + } + + [Fact] + public async Task Should_GroupBySport_When_AnomaliesSpanMultipleSports() + { + var idFb = new EventId("fb000000"); + var idBb = new EventId("bb000000"); + + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeAnomaly(idFb, score: 0.55m), + MakeAnomaly(idBb, score: 0.55m), + }.ToList().AsReadOnly()); + + _events.GetAsync(idFb, Arg.Any()).Returns(MakeEvent(idFb, 11)); + _events.GetAsync(idBb, Arg.Any()).Returns(MakeEvent(idBb, 6)); + + _results.GetAsync(idFb, Arg.Any()) + .Returns(new EventResult(idFb, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(idBb, Arg.Any()) + .Returns(new EventResult(idBb, 2, 0, Side.Side1, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.BySport.Select(b => b.Key) + .Should().BeEquivalentTo(new[] { "Sport.6", "Sport.11" }); + report.BySport.Single(b => b.Key == "Sport.11").HitRate.Should().Be(1.0m); + report.BySport.Single(b => b.Key == "Sport.6").HitRate.Should().Be(0.0m); + } + + [Fact] + public async Task Should_BuildSevenScoreBins_With_CanonicalKeys() + { + var id = new EventId("score000"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, score: 0.95m) }.ToList().AsReadOnly()); + + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.ByScoreBin.Should().HaveCount(7, "default buckets cover [0.30, 1.00] in 0.10-wide bins"); + report.ByScoreBin.Select(b => b.Key).Should().BeEquivalentTo( + new[] + { + "Bin.0.30-0.40", "Bin.0.40-0.50", "Bin.0.50-0.60", "Bin.0.60-0.70", + "Bin.0.70-0.80", "Bin.0.80-0.90", "Bin.0.90-1.00", + }, + options => options.WithStrictOrdering(), + "the page reads these literals to render labels"); + report.ByScoreBin.Last().Total.Should().Be(1, "score 0.95 should land in the [0.90, 1.00] bin"); + } + + [Theory] + [InlineData(0.30, "Bin.0.30-0.40")] + [InlineData(0.40, "Bin.0.40-0.50")] + [InlineData(0.5999, "Bin.0.50-0.60")] + [InlineData(0.60, "Bin.0.60-0.70")] + [InlineData(1.00, "Bin.0.90-1.00")] + public async Task Should_PlaceScore_InCorrectBin_AtBoundary(double scoreDouble, string expectedKey) + { + var score = (decimal)scoreDouble; + var id = new EventId("boundary"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, score) }.ToList().AsReadOnly()); + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + var bin = report.ByScoreBin.Single(b => b.Total == 1); + bin.Key.Should().Be(expectedKey); + } + + [Fact] + public async Task Should_ExtendScoreBinsBelow_When_DetectorThresholdIsLowered() + { + // Operator lowered Anomaly.OddsFlipThreshold to 0.10 → anomalies with + // score 0.15 exist. The histogram must still account for them. + var idLow = new EventId("lowscore"); + var idHigh = new EventId("hicscore"); + + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeAnomaly(idLow, score: 0.15m), + MakeAnomaly(idHigh, score: 0.85m), + }.ToList().AsReadOnly()); + + foreach (var id in new[] { idLow, idHigh }) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.ByScoreBin.Sum(b => b.Total).Should().Be(report.ResolvedCount, + "the histogram total must equal ResolvedCount regardless of detector tuning"); + report.ByScoreBin.First().Key.Should().Be("Bin.0.10-0.20", + "buckets are extended downward to include the lowest observed score"); + } + + [Fact] + public async Task Should_PopulateEventTitles_ForJoinedEvents() + { + var id = new EventId("title000"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, 0.55m) }.ToList().AsReadOnly()); + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.EventTitles.Should().ContainKey(id); + report.EventTitles[id].Should().Be("Team A vs Team B"); + } + + [Fact] + public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets() + { + var id = new EventId("orphan00"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly()); + + _events.GetAsync(id, Arg.Any()).Returns((Event?)null); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Resolved.Should().HaveCount(1, + "orphan anomalies are still evaluated for hit/miss"); + report.BySport.Should().BeEmpty( + "missing event metadata excludes the row from sport breakdown"); + } +} diff --git a/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyEvidenceParserTests.cs b/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyEvidenceParserTests.cs new file mode 100644 index 0000000..708f21b --- /dev/null +++ b/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyEvidenceParserTests.cs @@ -0,0 +1,122 @@ +using FluentAssertions; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Enums; + +namespace Marathon.Domain.Tests.AnomalyDetection; + +/// +/// Unit tests for covering happy path, +/// two-way (no draw), and malformed JSON tolerance. +/// +public sealed class AnomalyEvidenceParserTests +{ + [Fact] + public void Should_Parse_ThreeWayEvidence_With_DrawOutcome() + { + const string json = """ + { + "suspensionGapSeconds": 90, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.55, "pDraw": 0.20, "p2": 0.25, + "rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:02:30+03:00", + "p1": 0.25, "pDraw": 0.20, "p2": 0.55, + "rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8 + } + } + """; + + var parsed = AnomalyEvidenceParser.TryParse(json, out var data); + + parsed.Should().BeTrue(); + data.SuspensionGapSeconds.Should().Be(90); + data.PreSuspension.P1.Should().Be(0.55m); + data.PreSuspension.PDraw.Should().Be(0.20m); + data.PreSuspension.Favourite.Should().Be(Side.Side1, "Side1 had the highest pre-suspension probability"); + data.PostSuspension.Favourite.Should().Be(Side.Side2, "Side2 became favourite after the flip"); + } + + [Fact] + public void Should_Parse_TwoWayEvidence_With_NullDraw() + { + const string json = """ + { + "suspensionGapSeconds": 75, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.70, "p2": 0.30, + "rate1": 1.4, "rate2": 3.3 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:01:30+03:00", + "p1": 0.30, "p2": 0.70, + "rate1": 3.3, "rate2": 1.4 + } + } + """; + + var parsed = AnomalyEvidenceParser.TryParse(json, out var data); + + parsed.Should().BeTrue(); + data.PreSuspension.PDraw.Should().BeNull("tennis has no draw outcome"); + data.PreSuspension.RateDraw.Should().BeNull(); + data.PreSuspension.Favourite.Should().Be(Side.Side1); + data.PostSuspension.Favourite.Should().Be(Side.Side2); + } + + [Fact] + public void Should_ReturnFalse_When_JsonIsNullOrEmpty() + { + AnomalyEvidenceParser.TryParse(null, out _).Should().BeFalse(); + AnomalyEvidenceParser.TryParse(string.Empty, out _).Should().BeFalse(); + AnomalyEvidenceParser.TryParse(" ", out _).Should().BeFalse(); + } + + [Fact] + public void Should_ReturnFalse_When_JsonIsMalformed() + { + AnomalyEvidenceParser.TryParse("{not json", out _).Should().BeFalse(); + } + + [Fact] + public void Should_ReturnFalse_When_PreOrPostSuspensionMissing() + { + const string onlyPre = """ + { + "suspensionGapSeconds": 90, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.55, "p2": 0.25, "rate1": 1.8, "rate2": 4.0 + } + } + """; + + AnomalyEvidenceParser.TryParse(onlyPre, out _).Should().BeFalse(); + } + + [Fact] + public void Favourite_Should_Be_Draw_When_DrawIsMostLikely() + { + const string json = """ + { + "suspensionGapSeconds": 60, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.30, "pDraw": 0.50, "p2": 0.20, + "rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:01:00+03:00", + "p1": 0.30, "pDraw": 0.50, "p2": 0.20, + "rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0 + } + } + """; + + AnomalyEvidenceParser.TryParse(json, out var data).Should().BeTrue(); + data.PreSuspension.Favourite.Should().Be(Side.Draw); + } +} diff --git a/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyOutcomeEvaluatorTests.cs b/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyOutcomeEvaluatorTests.cs new file mode 100644 index 0000000..cbaca0b --- /dev/null +++ b/tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyOutcomeEvaluatorTests.cs @@ -0,0 +1,178 @@ +using FluentAssertions; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.AnomalyDetection; + +/// +/// Unit tests for covering the join with +/// for hit / miss / unresolved verdicts. +/// +public sealed class AnomalyOutcomeEvaluatorTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly EventId DefaultEventId = new("12345678"); + + private const string ThreeWayFlipJson = """ + { + "suspensionGapSeconds": 90, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.55, "pDraw": 0.20, "p2": 0.25, + "rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:02:30+03:00", + "p1": 0.25, "pDraw": 0.20, "p2": 0.55, + "rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8 + } + } + """; + + private static Anomaly MakeAnomaly(string evidenceJson, decimal score = 0.5m) => + new( + Id: Guid.NewGuid(), + EventId: DefaultEventId, + DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset), + Kind: AnomalyKind.SuspensionFlip, + Score: score, + EvidenceJson: evidenceJson); + + private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) => + new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow); + + [Fact] + public void Should_ReportHit_When_PostFlipFavourite_Wins() + { + // Post-flip favourite = Side2; result = Side2 wins → Hit. + var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m); + var result = MakeResult(Side.Side2, s1: 0, s2: 2); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit); + verdict.PreFlipFavourite.Should().Be(Side.Side1); + verdict.PostFlipFavourite.Should().Be(Side.Side2); + verdict.ActualWinner.Should().Be(Side.Side2); + verdict.Sport!.Value.Should().Be(6); + } + + [Fact] + public void Should_ReportMiss_When_PostFlipFavourite_Loses() + { + // Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong). + var anomaly = MakeAnomaly(ThreeWayFlipJson); + var result = MakeResult(Side.Side1, s1: 2, s2: 0); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss); + verdict.PostFlipFavourite.Should().Be(Side.Side2); + verdict.ActualWinner.Should().Be(Side.Side1); + } + + [Fact] + public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw() + { + var anomaly = MakeAnomaly(ThreeWayFlipJson); + var result = MakeResult(Side.Draw, s1: 1, s2: 1); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss); + verdict.ActualWinner.Should().Be(Side.Draw); + } + + [Fact] + public void Should_ReportUnresolved_When_ResultIsNull() + { + var anomaly = MakeAnomaly(ThreeWayFlipJson); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved); + verdict.ActualWinner.Should().BeNull(); + // Pre/post favourites still computed for display. + verdict.PreFlipFavourite.Should().Be(Side.Side1); + verdict.PostFlipFavourite.Should().Be(Side.Side2); + } + + [Fact] + public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed() + { + var anomaly = MakeAnomaly("{malformed"); + var result = MakeResult(Side.Side1); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved, + "evidence cannot be parsed so we cannot judge the prediction"); + verdict.PreFlipFavourite.Should().BeNull( + "fabricated favourites would mislead any consumer that reads the unresolved branch"); + verdict.PostFlipFavourite.Should().BeNull(); + verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced"); + } + + [Fact] + public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins() + { + const string twoWayJson = """ + { + "suspensionGapSeconds": 75, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:01:30+03:00", + "p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4 + } + } + """; + var anomaly = MakeAnomaly(twoWayJson, score: 0.55m); + var result = MakeResult(Side.Side2, s1: 0, s2: 2); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit); + verdict.PostFlipFavourite.Should().Be(Side.Side2); + } + + [Fact] + public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner() + { + // Tennis cannot draw — if the result is Draw the data is inconsistent + // with the evidence and we refuse to grade rather than silently miss-classify. + const string twoWayJson = """ + { + "suspensionGapSeconds": 75, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:01:30+03:00", + "p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4 + } + } + """; + var anomaly = MakeAnomaly(twoWayJson, score: 0.55m); + var result = MakeResult(Side.Draw); + + var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result); + + verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved); + verdict.ActualWinner.Should().Be(Side.Draw); + verdict.PostFlipFavourite.Should().Be(Side.Side2, + "favourite is still computed for display, just not graded"); + } + + [Fact] + public void Should_Throw_When_AnomalyIsNull() + { + var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null); + act.Should().Throw(); + } +}