refactor(bot): update paper trading models, broker integration, background services, and test project
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user