using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.Trading; using FinlyticCore.Services; using FinlyticEngine.Database; using FinlyticEngine.Database.Entities; using FinlyticEngine.Services.Mqtt; using FinlyticEngine.Settings; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace FinlyticEngine.Services.Trading; public record GetSetupsRpcRequest( bool TopPicksOnly = true, int Limit = 30, decimal? MinScore = 70.0m ); public class OpportunityPollerBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly IEngineRpcClient _rpcClient; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _logger; public OpportunityPollerBackgroundService( IServiceScopeFactory scopeFactory, IEngineRpcClient rpcClient, ISettingsService settingsService, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _rpcClient = rpcClient; _settingsService = settingsService; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] Starting background opportunity scanner."); // Initial grace delay for MQTT network stabilization await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.PollingIntervalSeconds, stoppingToken); await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] Querying active top-picks from FinlyticTechnicals..."); var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerMinScore, stoppingToken); var topPicksOnly = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerTopPicksOnly, stoppingToken); var limit = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerLimit, stoppingToken); var req = new GetSetupsRpcRequest(TopPicksOnly: topPicksOnly, Limit: limit, MinScore: minScore); var topPicks = await _rpcClient.SendRpcRequestAsync, GetSetupsRpcRequest>( "ta_GetSetups", req, TimeSpan.FromSeconds(5) ); // Task 3 (scan-universe visibility): only persist a cycle row once FinlyticTechnicals actually // answered - topPicks == null means the RPC itself timed out/failed (already logged/handled // below), which is a transport failure, not a legitimate "zero candidates this cycle" scan // outcome, so it deliberately does not get a row here. if (topPicks != null) { await PersistScanCycleAsync(req, topPicks, stoppingToken); } if (topPicks != null && topPicks.Count > 0) { await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] Received {Count} top-picks from FinlyticTechnicals. Evaluating opportunities...", topPicks.Count); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); foreach (var pick in topPicks) { if (stoppingToken.IsCancellationRequested) break; try { // Result is intentionally not surfaced anywhere beyond this log line: the poller is // an autonomous background scanner with no human waiting on a per-asset rejection // reason, unlike the on-demand RPC callers (AnalyzeController/EngineController). var evaluation = await lifecycleService.EvaluateAssetAsync( pick.Isin, pick.Symbol, forceAiEvaluation: false, triggerSource: TriggerSource.Automatic, triggeredByUserId: null, cancellationToken: stoppingToken); if (evaluation.Proposal == null) { await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] {Isin} evaluated, no proposal (COS={Cos:F1}, AiApproved={AiApproved}): {Reason}", pick.Isin, evaluation.CompositeScore, evaluation.AiApproved, evaluation.AiThesisSummary); } } catch (Exception ex) { await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, ex, "[OpportunityPoller] Failed to evaluate top-pick ISIN {Isin}", pick.Isin); } // Gentle throttle between evaluations await Task.Delay(250, stoppingToken); } } else { await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] No active top-picks available at this time."); } await Task.Delay(TimeSpan.FromSeconds(Math.Max(10, intervalSec)), stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { await _logger.LogErrorAsync(EngineSettingKeys.EngineChannel, ex, "[OpportunityPoller] Unexpected error in scanner cycle. Retrying in 30 seconds."); await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[OpportunityPoller] Background opportunity scanner stopped."); } /// /// Persists a minimal row recording exactly which ISINs /// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate /// set that ITradeLifecycleService.EvaluateAssetAsync is about to be called for (Task 3: /// scan-universe visibility). /// This is deliberately NOT the full universe FinlyticTechnicals monitors before that top-picks filter is /// applied (favorites/discovery/sentiment-spike ISINs live entirely inside /// FinlyticTechnicals.Services.TechnicalUniverseManager, out of scope for this table) - see the /// implementing task's report for why that broader pre-filter visibility was not added here. /// private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List topPicks, CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.ScanCycles.Add(new EngineScanCycleEntity { Id = Guid.NewGuid(), CycleStartedAtUtc = DateTime.UtcNow, RequestedLimit = request.Limit, RequestedMinScore = request.MinScore, CandidatesReturnedCount = topPicks.Count, CandidateIsins = topPicks.Select(p => p.Isin).ToList() }); await db.SaveChangesAsync(cancellationToken); } }