feat(insights): anomaly outcome validator — hit-rate calibration page
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) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||
|
||||
namespace Marathon.Application.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate report answering the question "is the SuspensionFlip detector right?".
|
||||
/// </summary>
|
||||
/// <param name="TotalAnomalies">Every persisted anomaly considered by this report.</param>
|
||||
/// <param name="ResolvedCount">Anomalies whose source events now have a final result.</param>
|
||||
/// <param name="UnresolvedCount">Anomalies still waiting for an event result.</param>
|
||||
/// <param name="HitCount">Resolved anomalies where the post-flip favourite won.</param>
|
||||
/// <param name="MissCount">Resolved anomalies where the post-flip favourite lost.</param>
|
||||
/// <param name="HitRate">
|
||||
/// <see cref="HitCount"/> ÷ <see cref="ResolvedCount"/> in [0, 1]. Null when no anomalies
|
||||
/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data".
|
||||
/// </param>
|
||||
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
|
||||
/// <param name="BySport">Breakdown by sport code.</param>
|
||||
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
|
||||
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
|
||||
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
|
||||
/// <param name="EventTitles">
|
||||
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id. Carried
|
||||
/// alongside the report so UI projections do not need a second pass over
|
||||
/// <c>IEventRepository</c> — every event in <see cref="Resolved"/> /
|
||||
/// <see cref="Unresolved"/> appears as a key. Missing events (e.g. pruned) are
|
||||
/// absent; consumers fall back to <c>EventId.Value</c>.
|
||||
/// </param>
|
||||
public sealed record AnomalyOutcomeReport(
|
||||
int TotalAnomalies,
|
||||
int ResolvedCount,
|
||||
int UnresolvedCount,
|
||||
int HitCount,
|
||||
int MissCount,
|
||||
decimal? HitRate,
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<ResolvedAnomaly> Resolved,
|
||||
IReadOnlyList<ResolvedAnomaly> Unresolved,
|
||||
IReadOnlyDictionary<DomainEventId, string> EventTitles);
|
||||
|
||||
/// <summary>
|
||||
/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)".
|
||||
/// </summary>
|
||||
/// <param name="Key">
|
||||
/// Stable, culture-invariant identifier used by the UI to localise the label
|
||||
/// (e.g. <c>"Severity.High"</c>, <c>"Sport.22723"</c>, <c>"Bin.0.60-0.70"</c>).
|
||||
/// </param>
|
||||
/// <param name="Total">Resolved anomalies in this bucket.</param>
|
||||
/// <param name="Hits">Subset of <see cref="Total"/> where post-flip favourite won.</param>
|
||||
/// <param name="HitRate">
|
||||
/// <see cref="Hits"/> ÷ <see cref="Total"/>, or null when <see cref="Total"/> is 0.
|
||||
/// </param>
|
||||
public sealed record OutcomeBucket(
|
||||
string Key,
|
||||
int Total,
|
||||
int Hits,
|
||||
decimal? HitRate);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Marathon.Application.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, culture-invariant <see cref="OutcomeBucket.Key"/> 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.
|
||||
/// </summary>
|
||||
public static class OutcomeBucketKeys
|
||||
{
|
||||
/// <summary>Prefix for sport-grouped buckets, e.g. <c>Sport.6</c>.</summary>
|
||||
public const string SportPrefix = "Sport.";
|
||||
|
||||
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
|
||||
public const string BinPrefix = "Bin.";
|
||||
|
||||
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
|
||||
public const string SeverityPrefix = "Severity.";
|
||||
|
||||
public const string SeverityLow = SeverityPrefix + "Low";
|
||||
public const string SeverityMedium = SeverityPrefix + "Medium";
|
||||
public const string SeverityHigh = SeverityPrefix + "High";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
|
||||
/// <see cref="Anomaly"/> with the originating event and its
|
||||
/// <see cref="EventResult"/>, then running the pure
|
||||
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class EvaluateAnomalyOutcomesUseCase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const decimal MinScore = 0.30m;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const decimal BinWidth = 0.10m;
|
||||
|
||||
private readonly IAnomalyRepository _anomalies;
|
||||
private readonly IEventRepository _events;
|
||||
private readonly IResultRepository _results;
|
||||
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
|
||||
|
||||
public EvaluateAnomalyOutcomesUseCase(
|
||||
IAnomalyRepository anomalies,
|
||||
IEventRepository events,
|
||||
IResultRepository results,
|
||||
ILogger<EvaluateAnomalyOutcomesUseCase> 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<AnomalyOutcomeReport> 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<DomainEventId, Event>(distinctEventIds.Count);
|
||||
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count);
|
||||
var eventTitles = new Dictionary<DomainEventId, string>(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<ResolvedAnomaly>();
|
||||
var unresolved = new List<ResolvedAnomaly>();
|
||||
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<OutcomeBucket> BuildSeverityBuckets(
|
||||
IReadOnlyCollection<ResolvedAnomaly> 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<OutcomeBucket> BuildSportBuckets(
|
||||
IReadOnlyCollection<ResolvedAnomaly> 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<OutcomeBucket> BuildScoreBins(
|
||||
IReadOnlyCollection<ResolvedAnomaly> 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<OutcomeBucket>();
|
||||
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<ResolvedAnomaly> items)
|
||||
{
|
||||
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? 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<OutcomeBucket>(),
|
||||
BySport: Array.Empty<OutcomeBucket>(),
|
||||
ByScoreBin: Array.Empty<OutcomeBucket>(),
|
||||
Resolved: Array.Empty<ResolvedAnomaly>(),
|
||||
Unresolved: Array.Empty<ResolvedAnomaly>(),
|
||||
EventTitles: new Dictionary<DomainEventId, string>());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed projection of the JSON payload written by <see cref="AnomalyDetector"/>
|
||||
/// into <see cref="Anomaly.EvidenceJson"/>. Captures pre- and post-suspension snapshots
|
||||
/// of normalised implied probabilities and raw rates for the Match-Win market.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The evaluator and any reader that needs to inspect an anomaly's evidence should
|
||||
/// parse via <see cref="AnomalyEvidenceParser.TryParse"/> rather than re-implement
|
||||
/// the JSON shape — the detector owns the schema.
|
||||
/// </remarks>
|
||||
public sealed record AnomalyEvidenceData(
|
||||
int SuspensionGapSeconds,
|
||||
AnomalyEvidenceSide PreSuspension,
|
||||
AnomalyEvidenceSide PostSuspension);
|
||||
|
||||
/// <summary>
|
||||
/// One side (pre or post) of a suspension interval. Probabilities are normalised
|
||||
/// so that <c>P1 + (PDraw ?? 0) + P2 == 1</c>. Two-way markets (e.g. tennis)
|
||||
/// leave <see cref="PDraw"/> and <see cref="RateDraw"/> null.
|
||||
/// </summary>
|
||||
public sealed record AnomalyEvidenceSide(
|
||||
DateTimeOffset CapturedAt,
|
||||
decimal P1,
|
||||
decimal? PDraw,
|
||||
decimal P2,
|
||||
decimal Rate1,
|
||||
decimal? RateDraw,
|
||||
decimal Rate2)
|
||||
{
|
||||
/// <summary>
|
||||
/// The side carrying the highest normalised implied probability — i.e.,
|
||||
/// the bookmaker's favourite at this point in time.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <see cref="Anomaly.EvidenceJson"/> string emitted by
|
||||
/// <see cref="AnomalyDetector"/>. Tolerant of malformed payloads — returns false
|
||||
/// rather than throwing so callers can skip un-parseable anomalies silently.
|
||||
/// </summary>
|
||||
public static class AnomalyEvidenceParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialise the evidence JSON. Returns <c>true</c> only when
|
||||
/// both pre- and post-suspension snapshots are present.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data)
|
||||
{
|
||||
data = default!;
|
||||
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<EvidenceDto>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict produced by comparing an anomaly's predicted post-flip favourite
|
||||
/// against the actual <see cref="EventResult.WinnerSide"/>.
|
||||
/// </summary>
|
||||
public enum AnomalyOutcomeKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The post-flip favourite (the side the bookmaker shortened odds on AFTER
|
||||
/// the suspension) ended up winning. The flip was directionally correct.
|
||||
/// </summary>
|
||||
Hit,
|
||||
|
||||
/// <summary>
|
||||
/// The post-flip favourite did NOT win. The flip pointed at the wrong side.
|
||||
/// </summary>
|
||||
Miss,
|
||||
|
||||
/// <summary>
|
||||
/// No <see cref="EventResult"/> is available yet — outcome cannot be judged.
|
||||
/// </summary>
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="PreFlipFavourite"/> and <see cref="PostFlipFavourite"/> are null
|
||||
/// when the anomaly's evidence JSON could not be parsed — the outcome will be
|
||||
/// <see cref="AnomalyOutcomeKind.Unresolved"/> in that case. Encoding the
|
||||
/// absence keeps consumers from being shown a fabricated side.
|
||||
/// </remarks>
|
||||
public sealed record ResolvedAnomaly(
|
||||
Guid AnomalyId,
|
||||
EventId EventId,
|
||||
DateTimeOffset DetectedAt,
|
||||
decimal Score,
|
||||
AnomalyKind Kind,
|
||||
SportCode? Sport,
|
||||
Side? PreFlipFavourite,
|
||||
Side? PostFlipFavourite,
|
||||
Side? ActualWinner,
|
||||
AnomalyOutcomeKind Outcome);
|
||||
@@ -0,0 +1,117 @@
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Pure domain function that evaluates whether a <see cref="AnomalyKind.SuspensionFlip"/>
|
||||
/// anomaly's prediction (the post-suspension favourite) matched the actual
|
||||
/// <see cref="EventResult.WinnerSide"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A "hit" is recorded when the side carrying the highest implied probability
|
||||
/// in <see cref="AnomalyEvidenceData.PostSuspension"/> equals
|
||||
/// <see cref="EventResult.WinnerSide"/>. For two-way markets (tennis), Draw is
|
||||
/// not a possible favourite — the evaluator naturally never emits Draw there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Stateless, deterministic, no I/O. Safe to call in tight loops.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AnomalyOutcomeEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates one anomaly against its event (optional metadata) and its result
|
||||
/// (optional — null when the match hasn't been graded yet).
|
||||
/// </summary>
|
||||
/// <param name="anomaly">The persisted anomaly.</param>
|
||||
/// <param name="sport">
|
||||
/// The event's sport — surfaced into <see cref="ResolvedAnomaly"/> so the UI
|
||||
/// can group by sport. Null when the originating event row is missing.
|
||||
/// </param>
|
||||
/// <param name="result">The event's final result, if known.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="ResolvedAnomaly"/> with <see cref="AnomalyOutcomeKind.Unresolved"/>
|
||||
/// when <paramref name="result"/> is null or the evidence JSON cannot be parsed,
|
||||
/// otherwise <see cref="AnomalyOutcomeKind.Hit"/> / <see cref="AnomalyOutcomeKind.Miss"/>.
|
||||
/// </returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the severity bucket boundaries that the UI
|
||||
/// pill / badge, the Insights breakdowns, and any future reporter share.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Buckets are inclusive on the left, exclusive on the right (except High
|
||||
/// which extends to 1.00 inclusive):
|
||||
/// <list type="bullet">
|
||||
/// <item>Low [<see cref="Low"/>, <see cref="Medium"/>)</item>
|
||||
/// <item>Medium [<see cref="Medium"/>, <see cref="High"/>)</item>
|
||||
/// <item>High [<see cref="High"/>, 1.00]</item>
|
||||
/// </list>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class AnomalySeverityThresholds
|
||||
{
|
||||
/// <summary>Lower bound of the Low bucket. Matches the detector's default flip threshold.</summary>
|
||||
public const decimal Low = 0.30m;
|
||||
|
||||
/// <summary>Lower bound of the Medium bucket.</summary>
|
||||
public const decimal Medium = 0.45m;
|
||||
|
||||
/// <summary>Lower bound of the High bucket.</summary>
|
||||
public const decimal High = 0.60m;
|
||||
}
|
||||
@@ -39,6 +39,10 @@
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
||||
<span>@L["Nav.Results"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="anomalies/insights">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Insights" Size="Size.Small" />
|
||||
<span>@L["Nav.Insights"]</span>
|
||||
</NavLink>
|
||||
|
||||
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
|
||||
<NavLink class="m-nav__link" href="settings">
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
|
||||
@L["Anomaly.Filter.MarkRead"]
|
||||
</button>
|
||||
<button type="button" class="m-chip" @onclick="OpenInsights" data-test="open-insights">
|
||||
@L["Nav.Insights"]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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<SharedResource> L
|
||||
@inject IAnomalyInsightsService InsightsService
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1 m-insights__header" data-test="insights-header">
|
||||
<div class="m-insights__header-text">
|
||||
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@L["Insights.Kicker"]
|
||||
</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Insights.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Insights.Lede"]</p>
|
||||
</div>
|
||||
<div class="m-insights__header-actions">
|
||||
<button type="button"
|
||||
class="m-chip m-insights__refresh"
|
||||
@onclick="LoadAsync"
|
||||
disabled="@_loading"
|
||||
data-test="insights-refresh">
|
||||
<span class="m-insights__refresh-glyph @(_loading ? "is-spinning" : null)" aria-hidden="true">↻</span>
|
||||
<span>@L["Insights.Action.Refresh"]</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (_loading && _vm is null)
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="insights-loading">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_errored && _vm is null)
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="insights-error">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-anomaly); color: var(--m-c-anomaly);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
|
||||
@L["Insights.Empty.None"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (_vm is { } vm)
|
||||
{
|
||||
@* ---------- KPI strip ---------- *@
|
||||
<div class="m-insights__kpis m-rise m-rise-2" data-test="insights-kpis">
|
||||
<article class="m-insights__kpi m-insights__kpi--@HitRateTone(vm.HitRate)" data-test="insights-kpi-hitrate">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.HitRate"]</span>
|
||||
<span class="m-insights__kpi-value">@FormatPercent(vm.HitRate)</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.HitRate.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi" data-test="insights-kpi-resolved">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Resolved"]</span>
|
||||
<span class="m-insights__kpi-value">
|
||||
@vm.ResolvedCount<span class="m-insights__kpi-denom"> / @vm.TotalAnomalies</span>
|
||||
</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.Resolved.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi" data-test="insights-kpi-unresolved">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Unresolved"]</span>
|
||||
<span class="m-insights__kpi-value">@vm.UnresolvedCount</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.Unresolved.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi m-insights__kpi--split" data-test="insights-kpi-hitsmisses">
|
||||
<div class="m-insights__split">
|
||||
<div class="m-insights__split-cell">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Hits"]</span>
|
||||
<span class="m-insights__kpi-value m-insights__kpi-value--positive">@vm.HitCount</span>
|
||||
</div>
|
||||
<div class="m-insights__split-divider" aria-hidden="true"></div>
|
||||
<div class="m-insights__split-cell">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Misses"]</span>
|
||||
<span class="m-insights__kpi-value m-insights__kpi-value--negative">@vm.MissCount</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By severity ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-severity">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.BySeverity"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.BySeverity, BucketRenderKind.Severity)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By sport ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.BySport"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.BySport, BucketRenderKind.Sport)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By score bin (7 fixed rows) ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-score">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.ByScore"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.ByScoreBin, BucketRenderKind.Score)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- Resolved table ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-4" data-test="insights-resolved">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.Resolved"]</span>
|
||||
<span class="m-insights__section-count m-mono">@vm.Resolved.Count</span>
|
||||
</header>
|
||||
|
||||
@if (vm.TotalAnomalies == 0)
|
||||
{
|
||||
<div class="m-list-empty" data-test="insights-empty-none">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
|
||||
@L["Insights.Empty.None"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (vm.Resolved.Count == 0)
|
||||
{
|
||||
<div class="m-list-empty" data-test="insights-empty-resolved">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
|
||||
@L["Insights.Empty.NoneResolved"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="m-insights__table-wrap">
|
||||
<table class="m-insights__table" data-test="insights-resolved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.Winner"]</th>
|
||||
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Resolved)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-insights__row m-insights__row--@OutcomeCss(local.Outcome)"
|
||||
data-test="insights-resolved-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||
<td style="font-weight: 500;">@local.EventTitle</td>
|
||||
<td>
|
||||
@if (local.Sport is { } sport)
|
||||
{
|
||||
<span class="m-insights__sport">
|
||||
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</td>
|
||||
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||
<td>@SideLabel(local.ActualWinner)</td>
|
||||
<td>
|
||||
<span class="m-insights__verdict m-insights__verdict--@OutcomeCss(local.Outcome)">
|
||||
@OutcomeLabel(local.Outcome)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-insights__open"
|
||||
data-test="insights-open-link"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"]
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@* ---------- Unresolved table (only when non-empty) ---------- *@
|
||||
@if (vm.Unresolved.Count > 0)
|
||||
{
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<section class="m-insights__section m-rise m-rise-5" data-test="insights-unresolved">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker" style="color: var(--m-c-ink-soft); border-color: var(--m-c-ink-soft);">
|
||||
@L["Insights.Section.Unresolved"]
|
||||
</span>
|
||||
<span class="m-insights__section-count m-mono">@vm.Unresolved.Count</span>
|
||||
</header>
|
||||
|
||||
<div class="m-insights__table-wrap m-insights__table-wrap--dim">
|
||||
<table class="m-insights__table" data-test="insights-unresolved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Unresolved)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-insights__row m-insights__row--pending"
|
||||
data-test="insights-unresolved-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||
<td>@local.EventTitle</td>
|
||||
<td>
|
||||
@if (local.Sport is { } sport)
|
||||
{
|
||||
<span class="m-insights__sport">
|
||||
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</td>
|
||||
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||
<td>
|
||||
<span class="m-insights__verdict m-insights__verdict--pending">
|
||||
@L["Insights.Outcome.Unresolved"]
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-insights__open"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"]
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-insights__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-5);
|
||||
align-items: end;
|
||||
}
|
||||
@@media (max-width: 720px) {
|
||||
.m-insights__header { grid-template-columns: 1fr; }
|
||||
.m-insights__header-actions { justify-self: start; }
|
||||
}
|
||||
.m-insights__header-text {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
max-width: 880px;
|
||||
}
|
||||
.m-insights__header-actions { display: flex; gap: var(--m-space-3); }
|
||||
|
||||
.m-insights__refresh {
|
||||
gap: var(--m-space-2);
|
||||
padding: 6px 12px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.m-insights__refresh:disabled { opacity: 0.6; cursor: progress; }
|
||||
.m-insights__refresh-glyph {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: rotate(45deg); }
|
||||
.m-insights__refresh-glyph.is-spinning { animation: m-insights-spin 1.1s linear infinite; }
|
||||
@@keyframes m-insights-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-insights__refresh-glyph.is-spinning { animation: none; }
|
||||
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: none; }
|
||||
}
|
||||
|
||||
/* ---- KPI strip ---- */
|
||||
.m-insights__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
.m-insights__kpi {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--m-space-2);
|
||||
position: relative;
|
||||
}
|
||||
.m-insights__kpi--positive { border-left-color: var(--m-c-positive); }
|
||||
.m-insights__kpi--neutral { border-left-color: var(--m-c-accent); }
|
||||
.m-insights__kpi--negative { border-left-color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-label {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__kpi-value {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(2rem, 3.5vw, 2.625rem);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
.m-insights__kpi--positive .m-insights__kpi-value { color: var(--m-c-positive); }
|
||||
.m-insights__kpi--negative .m-insights__kpi-value { color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-value--positive { color: var(--m-c-positive); }
|
||||
.m-insights__kpi-value--negative { color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-denom {
|
||||
font-size: 0.55em;
|
||||
color: var(--m-c-ink-soft);
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-insights__kpi-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__kpi--split { padding: var(--m-space-4) var(--m-space-5); }
|
||||
.m-insights__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-insights__split-cell { display: flex; flex-direction: column; gap: 6px; }
|
||||
.m-insights__split-cell:last-child { text-align: right; }
|
||||
.m-insights__split-divider {
|
||||
width: 1px;
|
||||
height: 56px;
|
||||
background: var(--m-c-rule);
|
||||
}
|
||||
|
||||
/* ---- Section headers ---- */
|
||||
.m-insights__section { display: grid; gap: var(--m-space-4); }
|
||||
.m-insights__section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-insights__section-count {
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
|
||||
/* ---- Bucket / breakdown grid ---- */
|
||||
.m-insights__buckets {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-insights__bucket-head,
|
||||
.m-insights__bucket-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.4fr) minmax(140px, 1fr) minmax(220px, 2fr);
|
||||
gap: var(--m-space-4);
|
||||
align-items: center;
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
}
|
||||
.m-insights__bucket-head {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-insights__bucket-row {
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-insights__bucket-row:last-child { border-bottom: 0; }
|
||||
.m-insights__bucket-row--dim { color: var(--m-c-ink-soft); }
|
||||
.m-insights__bucket-row--dim .m-insights__bucket-label { color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-insights__bucket-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-insights__bucket-label--mono {
|
||||
font-family: var(--m-font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.m-insights__bucket-counts {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__bucket-counts strong {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-insights__bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 56px;
|
||||
gap: var(--m-space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.m-insights__bar-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: var(--m-c-paper-2);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-insights__bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--m-c-accent);
|
||||
transition: width 320ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.m-insights__bar-fill--positive { background: var(--m-c-positive); }
|
||||
.m-insights__bar-fill--negative { background: var(--m-c-anomaly); }
|
||||
.m-insights__bar-fill--neutral { background: var(--m-c-accent); }
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-insights__bar-fill { transition: none; }
|
||||
}
|
||||
.m-insights__bar-pct {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-c-ink);
|
||||
text-align: right;
|
||||
}
|
||||
.m-insights__bar-na {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ---- Resolved / unresolved tables ---- */
|
||||
.m-insights__table-wrap {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.m-insights__table-wrap--dim { background: var(--m-c-paper-2); opacity: 0.92; }
|
||||
.m-insights__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--m-font-body);
|
||||
}
|
||||
.m-insights__table thead th {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: var(--m-space-3) var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-insights__table tbody td {
|
||||
padding: var(--m-space-3) var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
vertical-align: middle;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-insights__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.m-insights__row { transition: background 120ms ease; }
|
||||
.m-insights__row:hover { background: var(--m-c-paper-2); }
|
||||
.m-insights__row--hit { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
|
||||
.m-insights__row--miss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
|
||||
.m-insights__row--pending { box-shadow: inset 2px 0 0 0 var(--m-c-rule); }
|
||||
|
||||
.m-insights__sport {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-2);
|
||||
}
|
||||
.m-insights__sport-icon { --m-sport-size: 18px; }
|
||||
|
||||
.m-insights__verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--m-radius-xs);
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.m-insights__verdict--hit {
|
||||
color: var(--m-c-positive);
|
||||
background: rgba(21, 128, 61, 0.10);
|
||||
}
|
||||
.m-insights__verdict--miss {
|
||||
color: var(--m-c-anomaly);
|
||||
background: rgba(220, 38, 38, 0.10);
|
||||
}
|
||||
.m-insights__verdict--pending {
|
||||
color: var(--m-c-ink-soft);
|
||||
background: transparent;
|
||||
}
|
||||
[data-theme="dark"] .m-insights__verdict--hit {
|
||||
color: var(--m-c-positive);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
[data-theme="dark"] .m-insights__verdict--miss {
|
||||
color: var(--m-c-anomaly);
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
}
|
||||
|
||||
.m-insights__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--m-c-ink);
|
||||
border-bottom: 1px solid var(--m-c-accent);
|
||||
padding-bottom: 1px;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-insights__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
/* ---- Empty-state block (shared with feed) ---- */
|
||||
.m-list-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-7);
|
||||
text-align: center;
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@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<OutcomeBucket> 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<SportIcon>(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();
|
||||
}
|
||||
}
|
||||
@@ -305,4 +305,46 @@
|
||||
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
|
||||
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
|
||||
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
|
||||
|
||||
<data name="Nav.Insights"><value>Insights</value></data>
|
||||
<data name="Insights.Kicker"><value>Calibration</value></data>
|
||||
<data name="Insights.Title"><value>Did the flips predict the winner?</value></data>
|
||||
<data name="Insights.Lede"><value>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.</value></data>
|
||||
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Insights.Stat.HitRate.Hint"><value>Post-flip favourite won.</value></data>
|
||||
<data name="Insights.Stat.Resolved"><value>Resolved</value></data>
|
||||
<data name="Insights.Stat.Resolved.Hint"><value>Anomalies with a graded event.</value></data>
|
||||
<data name="Insights.Stat.Unresolved"><value>Unresolved</value></data>
|
||||
<data name="Insights.Stat.Unresolved.Hint"><value>Awaiting event result.</value></data>
|
||||
<data name="Insights.Stat.Hits"><value>Hits</value></data>
|
||||
<data name="Insights.Stat.Misses"><value>Misses</value></data>
|
||||
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
|
||||
<data name="Insights.Section.BySport"><value>By sport</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
|
||||
<data name="Insights.Column.DetectedAt"><value>Detected</value></data>
|
||||
<data name="Insights.Column.Match"><value>Match</value></data>
|
||||
<data name="Insights.Column.Sport"><value>Sport</value></data>
|
||||
<data name="Insights.Column.Score"><value>Score</value></data>
|
||||
<data name="Insights.Column.PreFavourite"><value>Pre-flip pick</value></data>
|
||||
<data name="Insights.Column.PostFavourite"><value>Post-flip pick</value></data>
|
||||
<data name="Insights.Column.Winner"><value>Actual winner</value></data>
|
||||
<data name="Insights.Column.Outcome"><value>Verdict</value></data>
|
||||
<data name="Insights.Column.Bucket"><value>Bucket</value></data>
|
||||
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Insights.Column.HitsOfTotal"><value>Hits / total</value></data>
|
||||
<data name="Insights.Outcome.Hit"><value>Hit</value></data>
|
||||
<data name="Insights.Outcome.Miss"><value>Miss</value></data>
|
||||
<data name="Insights.Outcome.Unresolved"><value>Pending</value></data>
|
||||
<data name="Insights.Side.Side1"><value>Side 1</value></data>
|
||||
<data name="Insights.Side.Side2"><value>Side 2</value></data>
|
||||
<data name="Insights.Side.Draw"><value>Draw</value></data>
|
||||
<data name="Insights.Side.Unknown"><value>—</value></data>
|
||||
<data name="Insights.Empty.None"><value>No anomalies have been recorded yet. Once the detector flags one and the matching event finishes, its verdict will appear here.</value></data>
|
||||
<data name="Insights.Empty.NoneResolved"><value>Anomalies exist but no matching events have been graded yet. Run the results loader or wait for matches to complete.</value></data>
|
||||
<data name="Insights.Action.Refresh"><value>Refresh</value></data>
|
||||
<data name="Insights.Action.OpenAnomaly"><value>Open</value></data>
|
||||
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
|
||||
</root>
|
||||
|
||||
@@ -318,4 +318,46 @@
|
||||
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
|
||||
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
|
||||
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
|
||||
|
||||
<data name="Nav.Insights"><value>Калибровка</value></data>
|
||||
<data name="Insights.Kicker"><value>Калибровка</value></data>
|
||||
<data name="Insights.Title"><value>Угадывают ли флипы победителя?</value></data>
|
||||
<data name="Insights.Lede"><value>Каждая зафиксированная аномалия suspension-flip сопоставлена с итогом матча. Hit rate показывает, оказался ли пост-флип фаворит реальным победителем — это и есть единственная метрика, говорящая, что детектор работает.</value></data>
|
||||
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Insights.Stat.HitRate.Hint"><value>Пост-флип фаворит выиграл.</value></data>
|
||||
<data name="Insights.Stat.Resolved"><value>Подтверждены</value></data>
|
||||
<data name="Insights.Stat.Resolved.Hint"><value>Аномалии с известным итогом.</value></data>
|
||||
<data name="Insights.Stat.Unresolved"><value>Без результата</value></data>
|
||||
<data name="Insights.Stat.Unresolved.Hint"><value>Ждём окончания матча.</value></data>
|
||||
<data name="Insights.Stat.Hits"><value>Попадания</value></data>
|
||||
<data name="Insights.Stat.Misses"><value>Промахи</value></data>
|
||||
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
|
||||
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
|
||||
<data name="Insights.Column.DetectedAt"><value>Замечено</value></data>
|
||||
<data name="Insights.Column.Match"><value>Матч</value></data>
|
||||
<data name="Insights.Column.Sport"><value>Вид спорта</value></data>
|
||||
<data name="Insights.Column.Score"><value>Score</value></data>
|
||||
<data name="Insights.Column.PreFavourite"><value>До флипа</value></data>
|
||||
<data name="Insights.Column.PostFavourite"><value>После флипа</value></data>
|
||||
<data name="Insights.Column.Winner"><value>Победитель</value></data>
|
||||
<data name="Insights.Column.Outcome"><value>Вердикт</value></data>
|
||||
<data name="Insights.Column.Bucket"><value>Группа</value></data>
|
||||
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Insights.Column.HitsOfTotal"><value>Попаданий / всего</value></data>
|
||||
<data name="Insights.Outcome.Hit"><value>Попадание</value></data>
|
||||
<data name="Insights.Outcome.Miss"><value>Промах</value></data>
|
||||
<data name="Insights.Outcome.Unresolved"><value>Ожидает</value></data>
|
||||
<data name="Insights.Side.Side1"><value>Сторона 1</value></data>
|
||||
<data name="Insights.Side.Side2"><value>Сторона 2</value></data>
|
||||
<data name="Insights.Side.Draw"><value>Ничья</value></data>
|
||||
<data name="Insights.Side.Unknown"><value>—</value></data>
|
||||
<data name="Insights.Empty.None"><value>Аномалии ещё не зафиксированы. Когда детектор отметит первую и матч завершится, его вердикт появится здесь.</value></data>
|
||||
<data name="Insights.Empty.NoneResolved"><value>Аномалии есть, но ни у одного из их событий нет результата. Запустите загрузчик результатов или подождите окончания матчей.</value></data>
|
||||
<data name="Insights.Action.Refresh"><value>Обновить</value></data>
|
||||
<data name="Insights.Action.OpenAnomaly"><value>Открыть</value></data>
|
||||
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Page-facing implementation of <see cref="IAnomalyInsightsService"/>. 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.
|
||||
/// </summary>
|
||||
public sealed class AnomalyInsightsService : IAnomalyInsightsService
|
||||
{
|
||||
private readonly EvaluateAnomalyOutcomesUseCase _useCase;
|
||||
|
||||
public AnomalyInsightsService(EvaluateAnomalyOutcomesUseCase useCase)
|
||||
{
|
||||
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
|
||||
}
|
||||
|
||||
public async Task<AnomalyInsightsVm> 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<DomainEventId, string> 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);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Marathon.Application.Reporting;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UI-facing projection of <see cref="AnomalyOutcomeReport"/>. Adds a resolved
|
||||
/// event title and severity bucket per row so the page never has to round-trip
|
||||
/// to a repository.
|
||||
/// </summary>
|
||||
public sealed record AnomalyInsightsVm(
|
||||
int TotalAnomalies,
|
||||
int ResolvedCount,
|
||||
int UnresolvedCount,
|
||||
int HitCount,
|
||||
int MissCount,
|
||||
decimal? HitRate,
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Resolved,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Unresolved);
|
||||
|
||||
/// <summary>
|
||||
/// One row in the resolved / unresolved drill-down list — anomaly + outcome +
|
||||
/// pre-shaped event title for the link-back affordance.
|
||||
/// </summary>
|
||||
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);
|
||||
@@ -1,3 +1,4 @@
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
@@ -83,12 +84,14 @@ public enum AnomalyFavourite
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>Helpers for severity bucketing.</summary>
|
||||
/// <summary>Helpers for severity bucketing. Thresholds come from
|
||||
/// <see cref="AnomalySeverityThresholds"/> so the UI badges and the
|
||||
/// Application-layer outcome report agree by construction.</summary>
|
||||
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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.EvaluateAnomalyOutcomesUseCase"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IAnomalyInsightsService
|
||||
{
|
||||
/// <summary>Builds the full report and projects it for the UI.</summary>
|
||||
Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public static class UiServicesExtensions
|
||||
// Browsing facades — Scoped so they capture the per-circuit repository scope.
|
||||
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
|
||||
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
|
||||
services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>();
|
||||
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EvaluateAnomalyOutcomesUseCase"/> covering empty
|
||||
/// state, mixed hit/miss aggregation, unresolved partitioning, and missing
|
||||
/// event metadata fallbacks.
|
||||
/// </summary>
|
||||
public sealed class EvaluateAnomalyOutcomesUseCaseTests
|
||||
{
|
||||
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
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<EvaluateAnomalyOutcomesUseCase>.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<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().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<CancellationToken>())
|
||||
.Returns(new[]
|
||||
{
|
||||
MakeAnomaly(id1, score: 0.65m),
|
||||
MakeAnomaly(id2, score: 0.40m),
|
||||
}.ToList().AsReadOnly());
|
||||
|
||||
_events.GetAsync(id1, Arg.Any<CancellationToken>()).Returns(MakeEvent(id1, 11));
|
||||
_events.GetAsync(id2, Arg.Any<CancellationToken>()).Returns(MakeEvent(id2, 6));
|
||||
|
||||
// id1 has a result → resolved; id2 has no result → unresolved.
|
||||
_results.GetAsync(id1, Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(id1, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||
_results.GetAsync(id2, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(ids.Select(id => MakeAnomaly(id, score: 0.55m)).ToList().AsReadOnly());
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
}
|
||||
|
||||
// Three hits (Side2 wins), one miss (Side1 wins).
|
||||
_results.GetAsync(ids[0], Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(ids[0], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||
_results.GetAsync(ids[1], Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(ids[1], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||
_results.GetAsync(ids[2], Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(ids[2], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||
_results.GetAsync(ids[3], Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new[]
|
||||
{
|
||||
MakeAnomaly(idFb, score: 0.55m),
|
||||
MakeAnomaly(idBb, score: 0.55m),
|
||||
}.ToList().AsReadOnly());
|
||||
|
||||
_events.GetAsync(idFb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idFb, 11));
|
||||
_events.GetAsync(idBb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idBb, 6));
|
||||
|
||||
_results.GetAsync(idFb, Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(idFb, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||
_results.GetAsync(idBb, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new[] { MakeAnomaly(id, score: 0.95m) }.ToList().AsReadOnly());
|
||||
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new[] { MakeAnomaly(id, score) }.ToList().AsReadOnly());
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new[] { MakeAnomaly(id, 0.55m) }.ToList().AsReadOnly());
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
|
||||
|
||||
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Enums;
|
||||
|
||||
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AnomalyEvidenceParser"/> covering happy path,
|
||||
/// two-way (no draw), and malformed JSON tolerance.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AnomalyOutcomeEvaluator"/> covering the join with
|
||||
/// <see cref="EventResult"/> for hit / miss / unresolved verdicts.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user