feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticEngine.Services.Trading;
|
||||
|
||||
public record GetCandlesRpcRequest(
|
||||
string Isin = "",
|
||||
string Timeframe = "15m"
|
||||
);
|
||||
|
||||
public class ActiveTradeMonitoringBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IEngineRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<ActiveTradeMonitoringBackgroundService> _logger;
|
||||
|
||||
public ActiveTradeMonitoringBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEngineRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<ActiveTradeMonitoringBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Starting active trade lifecycle monitoring service.");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.MonitoringIntervalSeconds, stoppingToken);
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||
|
||||
var activeTrades = await db.Trades
|
||||
.Include(t => t.Fills)
|
||||
.Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.BreakEvenTriggered || t.Status == TradeStatus.Tp1Hit || t.Status == TradeStatus.Tp2Hit)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (activeTrades.Count > 0)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Monitoring {Count} active trades against live price feeds.", activeTrades.Count);
|
||||
|
||||
foreach (var trade in activeTrades)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch latest candle for current price
|
||||
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, GetCandlesRpcRequest>(
|
||||
"ta_GetCandles",
|
||||
new GetCandlesRpcRequest(trade.UnderlyingIsin, "1m"),
|
||||
TimeSpan.FromSeconds(3)
|
||||
);
|
||||
|
||||
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var latestCandle = candles.Last();
|
||||
decimal currentPrice = latestCandle.Close;
|
||||
trade.CurrentPrice = currentPrice;
|
||||
trade.LastUpdatedAtUtc = DateTime.UtcNow;
|
||||
|
||||
// 2. Check Stop-Loss Violation
|
||||
bool isStoppedOut = false;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice <= trade.CurrentStopLoss)
|
||||
{
|
||||
isStoppedOut = true;
|
||||
}
|
||||
else if (trade.Direction == SignalDirection.Sell && currentPrice >= trade.CurrentStopLoss)
|
||||
{
|
||||
isStoppedOut = true;
|
||||
}
|
||||
|
||||
if (isStoppedOut)
|
||||
{
|
||||
trade.Status = TradeStatus.StoppedOut;
|
||||
trade.ClosedAtUtc = DateTime.UtcNow;
|
||||
if (trade.Direction == SignalDirection.Buy)
|
||||
{
|
||||
trade.RealizedPnlEur = ((currentPrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur;
|
||||
}
|
||||
else
|
||||
{
|
||||
trade.RealizedPnlEur = ((trade.AverageBuyIn - currentPrice) * trade.TotalQuantity) - trade.TotalFeesEur;
|
||||
}
|
||||
|
||||
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} STOPPED OUT at {Price:F2} € (SL: {SL:F2} €, PnL: {PnL:F2} €)",
|
||||
trade.Id, trade.UnderlyingIsin, currentPrice, trade.CurrentStopLoss, trade.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Check Break-Even Trigger (Free-Roll when TP1 is hit)
|
||||
bool isTp1Reached = false;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice >= trade.TakeProfit1)
|
||||
{
|
||||
isTp1Reached = true;
|
||||
}
|
||||
else if (trade.Direction == SignalDirection.Sell && currentPrice <= trade.TakeProfit1)
|
||||
{
|
||||
isTp1Reached = true;
|
||||
}
|
||||
|
||||
if (isTp1Reached && trade.Status == TradeStatus.Active)
|
||||
{
|
||||
decimal oldSl = trade.CurrentStopLoss;
|
||||
trade.CurrentStopLoss = trade.AverageBuyIn; // Move SL to Break-Even (Free-Roll)
|
||||
trade.Status = TradeStatus.BreakEvenTriggered;
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} hit TP1 ({TP1:F2} €). Moving SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
|
||||
trade.Id, trade.UnderlyingIsin, trade.TakeProfit1, oldSl, trade.AverageBuyIn);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
}
|
||||
|
||||
// 4. Check Trailing Stop logic
|
||||
if (trade.ExitPlan?.TrailingStopRule != null && trade.Status == TradeStatus.BreakEvenTriggered)
|
||||
{
|
||||
var rule = trade.ExitPlan.TrailingStopRule;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice > rule.ActivationPrice)
|
||||
{
|
||||
decimal trailingSl = currentPrice * 0.97m; // 3% trail
|
||||
if (trailingSl > trade.CurrentStopLoss)
|
||||
{
|
||||
trade.CurrentStopLoss = Math.Round(trailingSl, 2);
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trailing SL for trade {TradeId} moved up to {NewSl:F2} €",
|
||||
trade.Id, trade.CurrentStopLoss);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
|
||||
"[ActiveTradeMonitor] Error evaluating active trade {TradeId}", trade.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
|
||||
"[ActiveTradeMonitor] Unexpected error in monitoring loop. Waiting 15s.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Active trade lifecycle monitoring service stopped.");
|
||||
}
|
||||
|
||||
private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e)
|
||||
{
|
||||
decimal unrealizedPnlEur = 0m;
|
||||
decimal unrealizedPnlPercent = 0m;
|
||||
|
||||
if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0)
|
||||
{
|
||||
if (e.Direction == SignalDirection.Buy)
|
||||
{
|
||||
unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity;
|
||||
unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m;
|
||||
}
|
||||
else
|
||||
{
|
||||
unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity;
|
||||
unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m;
|
||||
}
|
||||
}
|
||||
|
||||
return new ActiveTradeDto(
|
||||
TradeId: e.Id,
|
||||
ProposalId: e.ProposalId,
|
||||
UnderlyingIsin: e.UnderlyingIsin,
|
||||
Symbol: e.Symbol,
|
||||
DerivativeIsin: e.DerivativeIsin,
|
||||
DerivativeWkn: e.DerivativeWkn,
|
||||
ExecutionMode: e.ExecutionMode,
|
||||
InstrumentType: e.InstrumentType,
|
||||
Direction: e.Direction,
|
||||
Status: e.Status,
|
||||
AverageBuyIn: e.AverageBuyIn,
|
||||
TotalQuantity: e.TotalQuantity,
|
||||
InitialStopLoss: e.InitialStopLoss,
|
||||
CurrentStopLoss: e.CurrentStopLoss,
|
||||
CurrentPrice: e.CurrentPrice,
|
||||
UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2),
|
||||
UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2),
|
||||
RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2),
|
||||
ExitPlan: e.ExitPlan,
|
||||
Fills: e.Fills.Select(f => new TradeFillDto(
|
||||
FillId: f.Id,
|
||||
ExecutedAtUtc: f.ExecutedAtUtc,
|
||||
Price: f.Price,
|
||||
Quantity: f.Quantity,
|
||||
Fee: f.Fee,
|
||||
Note: f.Note
|
||||
)).ToList(),
|
||||
OpenedAtUtc: e.OpenedAtUtc,
|
||||
ClosedAtUtc: e.ClosedAtUtc
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user