using System; 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.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticBot.Services.Ledger; public class SyntheticPaperBroker : ISyntheticPaperBroker { private readonly IServiceScopeFactory _scopeFactory; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _logger; public SyntheticPaperBroker( IServiceScopeFactory scopeFactory, ISettingsService settingsService, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _settingsService = settingsService; _logger = logger; } public async Task OpenPositionAsync( TradeProposalDto proposal, decimal quantity, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); decimal entryPrice = proposal.EntryPrice; decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0 ? proposal.ExitPlan.TakeProfitStages[0].TargetPrice : (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.05m : entryPrice * 0.95m); decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.10m : entryPrice * 0.90m); var position = new BotPositionEntity { Id = Guid.NewGuid(), ProposalId = proposal.ProposalId, Isin = proposal.UnderlyingIsin, Symbol = proposal.Symbol, Venue = BotExecutionVenue.SyntheticPaperBroker, ClientOrderId = $"SYN_{Guid.NewGuid():N}", Direction = proposal.Direction, Quantity = quantity, EntryPrice = entryPrice, AverageBuyIn = entryPrice, InitialStopLoss = proposal.InvalidationPrice, CurrentStopLoss = proposal.InvalidationPrice, CurrentPrice = entryPrice, TakeProfit1 = takeProfit1, TakeProfit2 = takeProfit2, TotalFeesEur = 1.00m, RealizedPnlEur = 0m, Status = BotPositionStatus.Active, ExitPlan = proposal.ExitPlan, OpenedAtUtc = DateTime.UtcNow, LastSyncAtUtc = DateTime.UtcNow }; db.Positions.Add(position); await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel, "[SyntheticBroker] Opened position {Id} for {Isin} ({Symbol}) at {Entry:F2} € (Qty: {Qty}, SL: {SL:F2}, TP1: {TP1:F2})", position.Id, position.Isin, position.Symbol, position.EntryPrice, position.Quantity, position.CurrentStopLoss, position.TakeProfit1); return position; } public async Task ClosePositionAsync( Guid positionId, decimal exitPrice, BotPositionStatus exitStatus, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var pos = await db.Positions.FirstOrDefaultAsync(p => p.Id == positionId, cancellationToken); if (pos == null) throw new InvalidOperationException($"Position {positionId} not found."); pos.Status = exitStatus; pos.ClosedAtUtc = DateTime.UtcNow; pos.CurrentPrice = exitPrice; pos.LastSyncAtUtc = DateTime.UtcNow; pos.TotalFeesEur += 1.00m; // Exit fee if (exitStatus == BotPositionStatus.KnockedOut) { pos.RealizedPnlEur = -((pos.AverageBuyIn * pos.Quantity) + pos.TotalFeesEur); } else { decimal pnl = pos.Direction == SignalDirection.Buy ? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur : ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur; pos.RealizedPnlEur = Math.Round(pnl, 2); } await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel, "[SyntheticBroker] Closed position {Id} at {Exit:F2} € with status {Status} (PnL: {PnL:F2} €)", pos.Id, exitPrice, exitStatus, pos.RealizedPnlEur); return pos; } public async Task GetSummaryAsync(CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken); var positions = await db.Positions.AsNoTracking().ToListAsync(cancellationToken); decimal totalRealized = positions.Sum(p => p.RealizedPnlEur); decimal totalFees = positions.Sum(p => p.TotalFeesEur); var openPositions = positions .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered) .ToList(); // Unrealized P&L of still-open positions (direction-aware: a short position gains when // CurrentPrice drops below AverageBuyIn). CurrentPrice is kept fresh by // BotTradeLifecycleBackgroundService, which re-fetches the latest candle close for every open // position on each monitoring tick. Without this term, equity only ever moved when a position // closed, even though open positions were already sitting on real gains/losses. decimal unrealizedPnl = openPositions.Sum(p => p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) * p.Quantity : (p.AverageBuyIn - p.CurrentPrice) * p.Quantity); decimal currentEquity = baseCapital + totalRealized + unrealizedPnl; decimal invested = openPositions.Sum(p => p.AverageBuyIn * p.Quantity); // Cash is equity minus the capital tied up in open positions at cost (AverageBuyIn), i.e. the // portion of the ledger not currently committed to a position - unrealized gains/losses on open // positions are reflected in `currentEquity` above but not in `cash` until the position closes. decimal cash = Math.Max(0m, currentEquity - invested); // BuyingPower = cash * 2.0 is a deliberate simplification (flat 2x leverage assumption for this // internal synthetic paper broker), not a real margin/buying-power calculation from a broker API. return new AccountSummaryDto( Equity: Math.Round(currentEquity, 2), Cash: Math.Round(cash, 2), BuyingPower: Math.Round(cash * 2.0m, 2), Currency: "EUR", Status: "Active" ); } }