Files
Finlytic/FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs
T

168 lines
7.1 KiB
C#

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<SyntheticPaperBroker> _logger;
public SyntheticPaperBroker(
IServiceScopeFactory scopeFactory,
ISettingsService settingsService,
IFinlyticLogger<SyntheticPaperBroker> logger)
{
_scopeFactory = scopeFactory;
_settingsService = settingsService;
_logger = logger;
}
public async Task<BotPositionEntity> OpenPositionAsync(
TradeProposalDto proposal,
decimal quantity,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
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<BotPositionEntity> ClosePositionAsync(
Guid positionId,
decimal exitPrice,
BotPositionStatus exitStatus,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
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<AccountSummaryDto> GetSummaryAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
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"
);
}
}