feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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<OpportunityPollerBackgroundService> _logger;
|
||||
|
||||
public OpportunityPollerBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEngineRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<OpportunityPollerBackgroundService> 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<List<StrategyResultDto>, 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<ITradeLifecycleService>();
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a minimal <see cref="EngineScanCycleEntity"/> row recording exactly which ISINs
|
||||
/// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate
|
||||
/// set that <c>ITradeLifecycleService.EvaluateAssetAsync</c> 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
|
||||
/// <c>FinlyticTechnicals.Services.TechnicalUniverseManager</c>, out of scope for this table) - see the
|
||||
/// implementing task's report for why that broader pre-filter visibility was not added here.
|
||||
/// </summary>
|
||||
private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List<StrategyResultDto> topPicks, CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user