using Domain.Entities.Forum.Posts.ValueObject;
using Domain.Entities.Members;
namespace Application.Helpers;
///
/// 예측 채점 + 트랙레코드 집계의 순수 로직 (D2 M4 예측 채점+트랙레코드, d2 §③ 채점 규칙 v1).
/// DB/Redis/HostedService 의존이 전혀 없어 단위 테스트가 가능하다 — Infrastructure 의
/// PredictionSettlementService 는 시세 조회/저장만 하고 판정·집계는 이 헬퍼에 위임한다.
///
public static class PredictionScorer
{
/// 단건 채점 입력 — 이미 조회된 시세/종목 상태를 그대로 넣는다.
/// 예측 방향
/// 작성 시점 기준가
/// 목표가 (없으면 null)
/// DueDate 종가 (없으면 null → Void)
/// 채점 시점 종목이 정상 거래 가능(상장 + TradingStatus.Normal)한지
public readonly record struct Input(
PredictionDirection Direction,
decimal BasePrice,
decimal? TargetPrice,
decimal? SettledPrice,
bool Tradable);
///
/// 단건 판정 (d2 §③ v1):
///
/// - 상폐/거래정지(!Tradable) 또는 시세 결측 → Void (표본 제외)
/// - TargetPrice 지정: Up 은 종가 ≥ 목표가면 Hit, Down 은 종가 ≤ 목표가면 Hit, 아니면 Miss
/// - 방향만: Up 은 종가 > 기준가면 Hit(동가 = Miss), Down 은 종가 < 기준가면 Hit(동가 = Miss)
///
///
public static PredictionStatus Score(in Input input)
{
// 상폐·거래정지·시세결측 → 무효 (표본 제외)
if (!input.Tradable || input.SettledPrice is not { } settled)
{
return PredictionStatus.Void;
}
if (input.TargetPrice is { } target)
{
var reached = input.Direction == PredictionDirection.Up ? settled >= target : settled <= target;
return reached ? PredictionStatus.Hit : PredictionStatus.Miss;
}
// 방향만 — 동가(0% 변동)는 Miss
var win = input.Direction == PredictionDirection.Up ? settled > input.BasePrice : settled < input.BasePrice;
return win ? PredictionStatus.Hit : PredictionStatus.Miss;
}
/// 트랙레코드 재집계 결과 (엔티티에 그대로 반영).
public readonly record struct TrackRecordAggregate(
int Predictions,
int Hits,
int Misses,
int Voids,
decimal HitRate,
int CurrentStreak,
int BestStreak,
TrackRecordTier Tier);
///
/// 채점 완료된 상태들을 시간순(오래된→최신)으로 받아 트랙레코드를 처음부터 재집계한다 (멱등).
/// HitRate = Hits/(Hits+Misses)*100 (소수 둘째자리 반올림, Void 제외).
/// 스트릭은 Void 를 건너뛴 Hit/Miss 시퀀스 기준: BestStreak = 최장 연속 Hit,
/// CurrentStreak = 가장 최근 Hit/Miss 부터 거슬러 올라간 연속 Hit(마지막이 Miss 면 0).
/// Pending 은 호출 전에 제외되어 있어야 한다.
///
public static TrackRecordAggregate Aggregate(IReadOnlyList chronological)
{
var hits = 0;
var misses = 0;
var voids = 0;
var bestStreak = 0;
var runningStreak = 0;
var currentStreak = 0;
foreach (var status in chronological)
{
switch (status)
{
case PredictionStatus.Hit:
hits++;
runningStreak++;
if (runningStreak > bestStreak)
{
bestStreak = runningStreak;
}
currentStreak++;
break;
case PredictionStatus.Miss:
misses++;
runningStreak = 0;
currentStreak = 0;
break;
case PredictionStatus.Void:
// 표본 제외 — 스트릭에 영향 없음(연속을 끊지도, 이어가지도 않음)
voids++;
break;
default:
// Pending 은 채점 완료 목록에 있으면 안 됨 — 방어적으로 무시
break;
}
}
var sample = hits + misses;
var hitRate = sample > 0 ? Math.Round((decimal)hits / sample * 100m, 2, MidpointRounding.AwayFromZero) : 0m;
var tier = TrackRecordTiers.Resolve(sample, hitRate);
return new TrackRecordAggregate(hits + misses + voids, hits, misses, voids, hitRate, currentStreak, bestStreak, tier);
}
}