Files
Finlytic/FinlyticAnalyzer/Services/WinRateCalculator.cs
T

176 lines
6.6 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services;
public class WinRateCalculator : IWinRateCalculator
{
private readonly ILogger<WinRateCalculator> _logger;
private readonly string _feedbackDir;
private readonly object _cacheLock = new();
private List<TradeFeedbackRecord>? _cachedRecords;
private DateTime _lastCacheTime = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(3);
public WinRateCalculator(ILogger<WinRateCalculator> logger)
{
_logger = logger;
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
if (!Directory.Exists(_feedbackDir))
{
Directory.CreateDirectory(_feedbackDir);
}
}
/// <summary>
/// Calculates the win rate for a given sector and symbol under the specified market regime.
/// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks.
/// </summary>
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
{
return CalculateDynamicWinRate(sector, symbol, regime);
}
/// <summary>
/// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime.
/// </summary>
public double CalculateDynamicWinRate(
string sector,
string symbol,
VixMarketRegime regime,
double? n8nEvalScore = null,
double? technicalScore = null,
double? sentimentScore = null,
double? fundamentalScore = null,
string signalType = "BUY")
{
try
{
// 1. N8n AI Confidence Score (Weight: 40%)
double n8nComponent = 62.0;
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
{
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
}
// 2. Technical Score (Weight: 30%)
double taComponent = 60.0;
if (technicalScore.HasValue && technicalScore.Value > 0)
{
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
}
// 3. Sentiment Score (Weight: 15%)
double sentComponent = 58.0;
if (sentimentScore.HasValue)
{
if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0)
{
// Map sentiment from -1.0..+1.0 into 35.0..85.0
sentComponent = 50.0 + (sentimentScore.Value * 25.0);
}
else
{
sentComponent = sentimentScore.Value;
}
}
// 4. Fundamental Score (Weight: 15%)
double fundComponent = 60.0;
if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
{
fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value;
}
// Multi-factor weighted composite
double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15);
// 5. Market Regime & Volatility Adjustment
double vixAdjustment = regime switch
{
VixMarketRegime.LowVol => +4.0, // Calm trending market
VixMarketRegime.Normal => +1.5, // Normal conditions
VixMarketRegime.HighVol => -3.5, // Increased whipsaws
VixMarketRegime.Panic => -8.0, // High panic / uncertainty
_ => 0.0
};
composite += vixAdjustment;
// 6. Historical track record calibration (if available in feedback records)
var records = GetCachedOrLoadRecords();
if (records.Count > 0)
{
var matching = records.Where(r =>
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
r.VixRegime == regime).ToList();
if (matching.Count >= 5)
{
int winningTrades = matching.Count(r => r.IsWin);
double historicalWinRate = (double)winningTrades / matching.Count * 100.0;
composite = (composite * 0.75) + (historicalWinRate * 0.25);
}
}
// Clamp between realistic financial statistical bounds (45.0% to 92.0%)
double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0);
_logger.LogInformation("[{Channel}] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
"AnalyzerChannel", symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
return finalWinRate;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", "AnalyzerChannel", symbol);
return 65.0;
}
}
private List<TradeFeedbackRecord> GetCachedOrLoadRecords()
{
lock (_cacheLock)
{
if (_cachedRecords != null && (DateTime.UtcNow - _lastCacheTime) < CacheTtl)
{
return _cachedRecords;
}
var loadedList = new List<TradeFeedbackRecord>();
if (Directory.Exists(_feedbackDir))
{
var jsonFiles = Directory.GetFiles(_feedbackDir, "*.json", SearchOption.AllDirectories);
foreach (var file in jsonFiles)
{
try
{
var content = File.ReadAllText(file);
var records = JsonSerializer.Deserialize<TradeFeedbackRecord[]>(content);
if (records != null && records.Length > 0)
{
loadedList.AddRange(records);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Failed to read or parse feedback file '{File}'", "AnalyzerChannel", file);
}
}
}
_cachedRecords = loadedList;
_lastCacheTime = DateTime.UtcNow;
return _cachedRecords;
}
}
}