refactor(bot): update paper trading models, broker integration, background services, and test project
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Database.Entities;
|
||||
using FinlyticBot.Services.Alpaca;
|
||||
using FinlyticBot.Services.Execution;
|
||||
using FinlyticBot.Services.Ledger;
|
||||
using FinlyticBot.Services.Mqtt;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticBot.Services.Monitoring;
|
||||
|
||||
public record BotGetCandlesRequest(string Isin, string Timeframe = "1m");
|
||||
|
||||
public class BotTradeLifecycleBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAlpacaTradingService _alpacaService;
|
||||
private readonly ISyntheticPaperBroker _syntheticBroker;
|
||||
private readonly IBotRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotTradeLifecycleBackgroundService> _logger;
|
||||
|
||||
public BotTradeLifecycleBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAlpacaTradingService alpacaService,
|
||||
ISyntheticPaperBroker syntheticBroker,
|
||||
IBotRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotTradeLifecycleBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_alpacaService = alpacaService;
|
||||
_syntheticBroker = syntheticBroker;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Starting Bot Trade Lifecycle & Trailing Monitoring Service...");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
DateTime lastSnapshotUtc = DateTime.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intervalSec = await _settingsService.GetSettingAsync(BotSettingKeys.MonitoringIntervalSeconds, stoppingToken);
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
var openPositions = await db.Positions
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (openPositions.Count > 0)
|
||||
{
|
||||
foreach (var pos in openPositions)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch live candle for price
|
||||
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, BotGetCandlesRequest>(
|
||||
"ta_GetCandles",
|
||||
new BotGetCandlesRequest(pos.Isin, "1m"),
|
||||
TimeSpan.FromSeconds(3)
|
||||
);
|
||||
|
||||
if (candles == null || candles.Count == 0) continue;
|
||||
|
||||
var lastCandle = candles.Last();
|
||||
decimal currentPrice = lastCandle.Close;
|
||||
pos.CurrentPrice = currentPrice;
|
||||
pos.LastSyncAtUtc = DateTime.UtcNow;
|
||||
|
||||
// 2. Check Stop-Loss Violation
|
||||
bool isStopped = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice <= pos.CurrentStopLoss
|
||||
: currentPrice >= pos.CurrentStopLoss;
|
||||
|
||||
if (isStopped)
|
||||
{
|
||||
pos.Status = BotPositionStatus.StoppedOut;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
pos.RealizedPnlEur = Math.Round(pnl, 2);
|
||||
|
||||
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} STOPPED OUT at {Price:F2} € (PnL: {PnL:F2} €)",
|
||||
pos.Id, pos.Isin, currentPrice, pos.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Check Take-Profit 1 -> Move SL to Break-Even (Free-Roll)
|
||||
bool isTp1 = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice >= pos.TakeProfit1
|
||||
: currentPrice <= pos.TakeProfit1;
|
||||
|
||||
if (isTp1 && pos.Status == BotPositionStatus.Active)
|
||||
{
|
||||
decimal oldSl = pos.CurrentStopLoss;
|
||||
pos.CurrentStopLoss = pos.AverageBuyIn;
|
||||
pos.Status = BotPositionStatus.BreakEvenTriggered;
|
||||
|
||||
if (pos.Venue == BotExecutionVenue.AlpacaPaperTrading && !string.IsNullOrWhiteSpace(pos.AlpacaOrderId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _alpacaService.UpdateStopLossAsync(pos.AlpacaOrderId, pos.CurrentStopLoss, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex,
|
||||
"[BotLifecycle] Failed to update Alpaca bracket stop-loss for order {Id}", pos.AlpacaOrderId);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} reached TP1 ({TP1:F2} €). Moved SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
|
||||
pos.Id, pos.Isin, pos.TakeProfit1, oldSl, pos.AverageBuyIn);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
|
||||
// 4. Check Take-Profit 2
|
||||
bool isTp2 = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice >= pos.TakeProfit2
|
||||
: currentPrice <= pos.TakeProfit2;
|
||||
|
||||
if (isTp2)
|
||||
{
|
||||
pos.Status = BotPositionStatus.Closed;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
pos.RealizedPnlEur = Math.Round(pnl, 2);
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} reached TP2 ({TP2:F2} €). Closed with profit {PnL:F2} €",
|
||||
pos.Id, pos.Isin, pos.TakeProfit2, pos.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Trailing Stop Rule check
|
||||
if (pos.Status == BotPositionStatus.BreakEvenTriggered && pos.ExitPlan?.TrailingStopRule != null)
|
||||
{
|
||||
if (pos.Direction == SignalDirection.Buy)
|
||||
{
|
||||
decimal trail = currentPrice * 0.97m;
|
||||
if (trail > pos.CurrentStopLoss)
|
||||
{
|
||||
pos.CurrentStopLoss = Math.Round(trail, 2);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, ex,
|
||||
"[BotLifecycle] Error monitoring bot position {Id}", pos.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic Daily Snapshot
|
||||
if (DateTime.UtcNow - lastSnapshotUtc >= TimeSpan.FromHours(1))
|
||||
{
|
||||
var allPositions = await db.Positions.AsNoTracking().ToListAsync(stoppingToken);
|
||||
decimal realized = allPositions.Sum(p => p.RealizedPnlEur);
|
||||
decimal unrealized = allPositions
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered)
|
||||
.Sum(p => (p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) : (p.AverageBuyIn - p.CurrentPrice)) * p.Quantity);
|
||||
|
||||
int closedCount = allPositions.Count(p => p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut);
|
||||
int winCount = allPositions.Count(p => (p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut) && p.RealizedPnlEur > 0);
|
||||
decimal winRate = closedCount > 0 ? ((decimal)winCount / closedCount) * 100m : 0m;
|
||||
|
||||
// Same configured base capital as SyntheticPaperBroker.GetSummaryAsync (Rules.md §4:
|
||||
// no hardcoded financial constants) - this used to be a literal 50000m that could
|
||||
// silently drift from the actual configured Bot.SyntheticBaseCapitalEur setting.
|
||||
decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, stoppingToken);
|
||||
|
||||
db.PortfolioSnapshots.Add(new BotPortfolioSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SnapshotDateUtc = DateTime.UtcNow,
|
||||
TotalEquityEur = baseCapital + realized + unrealized,
|
||||
CashEur = baseCapital + realized,
|
||||
OpenPositionsCount = openPositions.Count,
|
||||
DailyRealizedPnlEur = realized,
|
||||
TotalUnrealizedPnlEur = unrealized,
|
||||
WinRatePercent = Math.Round(winRate, 2),
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
lastSnapshotUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(BotSettingKeys.LifecycleChannel, ex,
|
||||
"[BotLifecycle] Unexpected error in bot lifecycle loop. Retrying in 15s.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Bot Trade Lifecycle Monitoring Service stopped.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user