feat(Trades): refactor trades MQTT client and DTOs
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticTrades.Database;
|
||||
using FinlyticTrades.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Parquet.Serialization;
|
||||
|
||||
namespace FinlyticTrades.Services;
|
||||
|
||||
public interface IFeedbackExporterEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports feedback data for closed trades.
|
||||
/// </summary>
|
||||
Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<FeedbackExporterEngine> _logger;
|
||||
private readonly string _feedbackDir;
|
||||
|
||||
public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, ILogger<FeedbackExporterEngine> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
||||
|
||||
if (!Directory.Exists(_feedbackDir))
|
||||
{
|
||||
Directory.CreateDirectory(_feedbackDir);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Feedback Exporter Engine background service started.", "TradesChannel");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExportFeedbackDataAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error executing feedback exporter job.", "TradesChannel");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Feedback Exporter Engine background service stopped.", "TradesChannel");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports feedback data for closed trades into sector-based JSON and Parquet formats.
|
||||
/// Uses atomic file-writes to avoid thread-lock conflicts with reader processes.
|
||||
/// </summary>
|
||||
public async Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TradesDbContext>();
|
||||
|
||||
var closedTrades = await dbContext.Trades
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Status == TradeStatus.Closed && t.UserExitPrice.HasValue)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (closedTrades.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] No closed trades available for export.", "TradesChannel");
|
||||
return;
|
||||
}
|
||||
|
||||
var groups = closedTrades.GroupBy(t => SanitizeSectorName(t.Sector));
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var sectorName = group.Key;
|
||||
var sectorDir = Path.Combine(_feedbackDir, sectorName);
|
||||
|
||||
if (!Directory.Exists(sectorDir))
|
||||
{
|
||||
Directory.CreateDirectory(sectorDir);
|
||||
}
|
||||
|
||||
var feedbackRecords = new List<TradeFeedbackRecord>();
|
||||
|
||||
foreach (var t in group)
|
||||
{
|
||||
var startTime = t.ExecutionTimestamp ?? t.CreatedAt;
|
||||
var endTime = t.UserExitTimestamp ?? t.ClosedAt ?? DateTime.UtcNow;
|
||||
double reactionDelay = Math.Max(0, (endTime - startTime).TotalMinutes);
|
||||
|
||||
decimal exitPrice = t.UserExitPrice ?? t.EntryPrice;
|
||||
|
||||
decimal entryPrice = t.ActualEntryPrice.HasValue && t.ActualEntryPrice.Value > 0
|
||||
? t.ActualEntryPrice.Value
|
||||
: t.EntryPrice;
|
||||
|
||||
decimal slippagePct = t.EntryPrice > 0
|
||||
? Math.Abs((entryPrice - t.EntryPrice) / t.EntryPrice) * 100.0m
|
||||
: 0m;
|
||||
|
||||
var rec = new TradeFeedbackRecord
|
||||
{
|
||||
TradeId = t.TradeId,
|
||||
AnalysisId = t.AnalysisId,
|
||||
Sector = t.Sector,
|
||||
Symbol = t.Symbol,
|
||||
Isin = t.Isin,
|
||||
EntryPrice = entryPrice,
|
||||
StopLoss = t.StopLoss,
|
||||
TakeProfit = t.TakeProfit,
|
||||
UserExitPrice = exitPrice,
|
||||
PnlAbsolute = t.PnlAbsolute ?? 0m,
|
||||
PnlPercent = t.PnlPercent ?? 0m,
|
||||
IsWin = t.IsWin ?? false,
|
||||
CloseReason = t.CloseReason ?? "Unknown",
|
||||
VixRegime = t.VixRegime,
|
||||
VixValue = t.VixValue,
|
||||
ReactionDelayMinutes = Math.Round(reactionDelay, 2),
|
||||
SlippagePercent = Math.Round(slippagePct, 2),
|
||||
CreatedAt = t.CreatedAt,
|
||||
ClosedAt = endTime
|
||||
};
|
||||
|
||||
feedbackRecords.Add(rec);
|
||||
}
|
||||
|
||||
// 1. Atomic JSON Export (.tmp -> move)
|
||||
string jsonPath = Path.Combine(sectorDir, $"{sectorName}_feedback.json");
|
||||
string jsonTmpPath = Path.Combine(sectorDir, $"{sectorName}_feedback.json.tmp");
|
||||
string jsonContent = JsonSerializer.Serialize(feedbackRecords, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
await File.WriteAllTextAsync(jsonTmpPath, jsonContent, cancellationToken);
|
||||
File.Move(jsonTmpPath, jsonPath, overwrite: true);
|
||||
|
||||
// 2. Atomic Parquet Export (.tmp -> move)
|
||||
try
|
||||
{
|
||||
string parquetPath = Path.Combine(sectorDir, $"{sectorName}_feedback.parquet");
|
||||
string parquetTmpPath = Path.Combine(sectorDir, $"{sectorName}_feedback.parquet.tmp");
|
||||
|
||||
await using (var fileStream = new FileStream(parquetTmpPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true))
|
||||
{
|
||||
await ParquetSerializer.SerializeAsync(feedbackRecords, fileStream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(parquetTmpPath, parquetPath, overwrite: true);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Exported Parquet feedback file for sector '{Sector}' to {ParquetPath}", "TradesChannel", sectorName, parquetPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to write Parquet file for sector '{Sector}'. JSON file was written successfully.", "TradesChannel", sectorName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully exported feedback data for {Count} closed trades across {Sectors} sectors.",
|
||||
"TradesChannel", closedTrades.Count, groups.Count());
|
||||
}
|
||||
|
||||
private static string SanitizeSectorName(string? sector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sector)) return "general";
|
||||
|
||||
var clean = Regex.Replace(sector.Trim().ToLowerInvariant(), @"[^a-z0-9_\-]", "_");
|
||||
return string.IsNullOrWhiteSpace(clean) ? "general" : clean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using FinlyticTrades.Database;
|
||||
using FinlyticTrades.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FinlyticTrades.Services;
|
||||
|
||||
public interface ISettingsDbService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current settings.
|
||||
/// </summary>
|
||||
Task<TradesSettingsEntity> GetSettingsAsync();
|
||||
/// <summary>
|
||||
/// Saves the provided settings.
|
||||
/// </summary>
|
||||
Task<TradesSettingsEntity> SaveSettingsAsync(TradesSettingsEntity settings);
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary of key-value pairs.
|
||||
/// </summary>
|
||||
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
||||
}
|
||||
|
||||
public class SettingsDbService : ISettingsDbService
|
||||
{
|
||||
private readonly TradesDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SettingsDbService class.
|
||||
/// </summary>
|
||||
public SettingsDbService(TradesDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current settings.
|
||||
/// </summary>
|
||||
public async Task<TradesSettingsEntity> GetSettingsAsync()
|
||||
{
|
||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new TradesSettingsEntity { Id = Guid.NewGuid() };
|
||||
_context.Settings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the provided settings.
|
||||
/// </summary>
|
||||
public async Task<TradesSettingsEntity> SaveSettingsAsync(TradesSettingsEntity settings)
|
||||
{
|
||||
var existing = await _context.Settings.FirstOrDefaultAsync();
|
||||
if (existing == null)
|
||||
{
|
||||
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
|
||||
_context.Settings.Add(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.AtrStopLossMultiplier = settings.AtrStopLossMultiplier;
|
||||
existing.RiskPerTradePercentage = settings.RiskPerTradePercentage;
|
||||
existing.MaxOpenPositions = settings.MaxOpenPositions;
|
||||
existing.UpdatedAt = settings.UpdatedAt;
|
||||
_context.Settings.Update(existing);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary of key-value pairs.
|
||||
/// </summary>
|
||||
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
foreach (var (key, value) in dictionary)
|
||||
{
|
||||
if (string.Equals(key, "AtrStopLossMultiplier", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var atr))
|
||||
settings.AtrStopLossMultiplier = atr;
|
||||
else if (string.Equals(key, "RiskPerTradePercentage", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var risk))
|
||||
settings.RiskPerTradePercentage = risk;
|
||||
else if (string.Equals(key, "MaxOpenPositions", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var maxPos))
|
||||
settings.MaxOpenPositions = maxPos;
|
||||
}
|
||||
|
||||
settings.UpdatedAt = DateTime.UtcNow;
|
||||
await SaveSettingsAsync(settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticTrades.Database;
|
||||
using FinlyticTrades.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticTrades.Services;
|
||||
|
||||
public interface ITradeLifecycleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes a proposed trade.
|
||||
/// </summary>
|
||||
Task<bool> ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Processes a manual analysis RPC response from FinlyticAnalyzer and ingests it if a trade was proposed.
|
||||
/// </summary>
|
||||
Task<bool> ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a trade proposal and maps execution parameters.
|
||||
/// </summary>
|
||||
Task<TradeEntity?> AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an hourly update for a trade.
|
||||
/// </summary>
|
||||
Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of active trades filtered by optional UserId.
|
||||
/// </summary>
|
||||
Task<List<TradeEntity>> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of trades filtered by ISIN, status, and optional UserId.
|
||||
/// </summary>
|
||||
Task<List<TradeEntity>> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes a trade manually.
|
||||
/// </summary>
|
||||
Task<TradeEntity?> CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a trade proposal.
|
||||
/// </summary>
|
||||
Task<TradeEntity?> RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class TradeLifecycleService : ITradeLifecycleService
|
||||
{
|
||||
private readonly TradesDbContext _dbContext;
|
||||
private readonly ILogger<TradeLifecycleService> _logger;
|
||||
|
||||
public TradeLifecycleService(TradesDbContext dbContext, ILogger<TradeLifecycleService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a manual analysis RPC response from FinlyticAnalyzer and ingests it if a trade was proposed.
|
||||
/// </summary>
|
||||
public async Task<bool> ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (response == null || !response.IsTradeProposed)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Manual analysis response indicated NO trade proposed (AnalysisId: {AnalysisId}). Skipping.", "TradesChannel", response?.AnalysisId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.Proposal != null)
|
||||
{
|
||||
response.Proposal.UserId = userId;
|
||||
return await ProcessProposedTradeAsync(response.Proposal, cancellationToken);
|
||||
}
|
||||
|
||||
if (response.N8nResponse != null)
|
||||
{
|
||||
var n8n = response.N8nResponse;
|
||||
var exec = n8n.ExecutionPlan;
|
||||
|
||||
var generatedProposal = new TradeProposalDto
|
||||
{
|
||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
||||
AnalysisId = response.AnalysisId,
|
||||
EventId = response.AnalysisId,
|
||||
UserId = userId,
|
||||
IsGlobalProposal = false,
|
||||
Status = "Proposed",
|
||||
SignalType = string.Equals(n8n.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
||||
RiskTolerance = n8n.SuggestedRisk,
|
||||
Timeframe = n8n.SuggestedTimeframe,
|
||||
Reasoning = n8n.AiReasoning,
|
||||
StopLoss = exec?.StopLoss ?? 0m,
|
||||
TakeProfit = exec?.TakeProfitTargets?.FirstOrDefault() ?? 0m,
|
||||
EntryZoneMin = exec?.EntryZone?.Min,
|
||||
EntryZoneMax = exec?.EntryZone?.Max,
|
||||
TakeProfitTargets = exec?.TakeProfitTargets,
|
||||
RiskRewardRatio = exec?.RiskRewardRatio,
|
||||
MaxLeverage = exec?.MaxLeverage,
|
||||
TechnicalRationale = n8n.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
||||
FundamentalRationale = n8n.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
||||
RiskWarning = n8n.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await ProcessProposedTradeAsync(generatedProposal, cancellationToken);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a proposed trade.
|
||||
/// </summary>
|
||||
public async Task<bool> ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin))
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] ProcessProposedTradeAsync: Received proposal with missing Symbol and ISIN. Skipping.", "TradesChannel");
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetStatus = string.Equals(proposal.Status, "Rejected", StringComparison.OrdinalIgnoreCase)
|
||||
? TradeStatus.Rejected
|
||||
: TradeStatus.Proposed;
|
||||
|
||||
var existingTrade = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t =>
|
||||
(!string.IsNullOrWhiteSpace(proposal.TradeId) && t.TradeId == proposal.TradeId) ||
|
||||
(!string.IsNullOrWhiteSpace(proposal.AnalysisId) && t.AnalysisId == proposal.AnalysisId),
|
||||
cancellationToken);
|
||||
|
||||
if (existingTrade != null)
|
||||
{
|
||||
if (existingTrade.Status != TradeStatus.Active && existingTrade.Status != TradeStatus.Closed)
|
||||
{
|
||||
existingTrade.Status = targetStatus;
|
||||
}
|
||||
|
||||
MapProposalToEntity(proposal, existingTrade);
|
||||
_dbContext.Trades.Update(existingTrade);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully UPDATED trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}",
|
||||
"TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
string tradeId = !string.IsNullOrWhiteSpace(proposal.TradeId) ? proposal.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant());
|
||||
|
||||
var tradeEntity = new TradeEntity
|
||||
{
|
||||
TradeId = tradeId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
MapProposalToEntity(proposal, tradeEntity);
|
||||
tradeEntity.Status = targetStatus;
|
||||
|
||||
_dbContext.Trades.Add(tradeEntity);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}",
|
||||
"TradesChannel", tradeId, proposal.Symbol, proposal.Isin, targetStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a trade proposal and updates execution parameters.
|
||||
/// </summary>
|
||||
public async Task<TradeEntity?> AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string targetUserId = !string.IsNullOrWhiteSpace(request.UserId) ? request.UserId : "default_user";
|
||||
|
||||
var existingTrade = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t =>
|
||||
(!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) ||
|
||||
(!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId), cancellationToken);
|
||||
|
||||
if (existingTrade != null)
|
||||
{
|
||||
if (existingTrade.Status == TradeStatus.Closed)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Refused to accept trade {TradeId} because its status is CLOSED", "TradesChannel", existingTrade.TradeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
existingTrade.Status = TradeStatus.Active;
|
||||
existingTrade.IsGlobalProposal = false;
|
||||
existingTrade.UserId = targetUserId;
|
||||
|
||||
if (request.ActualEntryPrice > 0) existingTrade.ActualEntryPrice = request.ActualEntryPrice;
|
||||
if (request.EntryPrice > 0) existingTrade.EntryPrice = request.EntryPrice.Value;
|
||||
if (request.PositionSize > 0) existingTrade.PositionSize = request.PositionSize;
|
||||
if (request.LeverageUsed > 0) existingTrade.LeverageUsed = request.LeverageUsed;
|
||||
if (request.Quantity > 0) existingTrade.Quantity = request.Quantity;
|
||||
if (request.EntryFee.HasValue) existingTrade.EntryFee = request.EntryFee;
|
||||
if (request.ExitFee.HasValue) existingTrade.ExitFee = request.ExitFee;
|
||||
if (request.StopLoss > 0) existingTrade.StopLoss = request.StopLoss.Value;
|
||||
if (request.TakeProfit > 0) existingTrade.TakeProfit = request.TakeProfit.Value;
|
||||
if (request.KnockoutThreshold > 0) existingTrade.KnockoutThreshold = request.KnockoutThreshold;
|
||||
if (!string.IsNullOrWhiteSpace(request.Timeframe)) existingTrade.Timeframe = request.Timeframe;
|
||||
if (!string.IsNullOrWhiteSpace(request.Reasoning)) existingTrade.Reasoning = request.Reasoning;
|
||||
|
||||
existingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow;
|
||||
|
||||
existingTrade.PnlAbsolute = -(existingTrade.EntryFee ?? 0m) - (existingTrade.ExitFee ?? 0m);
|
||||
if (existingTrade.PositionSize > 0)
|
||||
{
|
||||
existingTrade.PnlPercent = (existingTrade.PnlAbsolute / existingTrade.PositionSize) * 100m;
|
||||
}
|
||||
|
||||
_dbContext.Trades.Update(existingTrade);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully ACCEPTED and UPDATED trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", existingTrade.TradeId, existingTrade.Isin, existingTrade.UserId);
|
||||
return existingTrade;
|
||||
}
|
||||
|
||||
var proposal = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t => t.IsGlobalProposal &&
|
||||
(!string.IsNullOrEmpty(request.AnalysisId) ? t.AnalysisId == request.AnalysisId : t.Isin == request.Isin),
|
||||
cancellationToken);
|
||||
|
||||
var targetTradeId = !string.IsNullOrWhiteSpace(request.TradeId) ? request.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant());
|
||||
|
||||
var newTrade = new TradeEntity
|
||||
{
|
||||
TradeId = targetTradeId,
|
||||
AnalysisId = proposal?.AnalysisId ?? (string.IsNullOrWhiteSpace(request.AnalysisId) ? Guid.NewGuid().ToString("N") : request.AnalysisId),
|
||||
EventId = proposal?.EventId ?? request.AnalysisId,
|
||||
Sector = proposal?.Sector ?? "General",
|
||||
Symbol = proposal?.Symbol ?? request.Symbol ?? request.Isin,
|
||||
Isin = proposal?.Isin ?? request.Isin,
|
||||
CompanyName = proposal?.CompanyName ?? request.Symbol ?? request.Isin,
|
||||
Status = TradeStatus.Active,
|
||||
IsGlobalProposal = false,
|
||||
UserId = targetUserId,
|
||||
|
||||
EntryPrice = proposal?.EntryPrice ?? request.EntryPrice ?? request.ActualEntryPrice ?? 0m,
|
||||
StopLoss = request.StopLoss > 0 ? request.StopLoss.Value : (proposal?.StopLoss ?? 0m),
|
||||
TakeProfit = request.TakeProfit > 0 ? request.TakeProfit.Value : (proposal?.TakeProfit ?? 0m),
|
||||
SignalType = proposal?.SignalType ?? request.SignalType ?? "BUY",
|
||||
RiskTolerance = proposal?.RiskTolerance ?? "Moderate",
|
||||
Timeframe = proposal?.Timeframe ?? request.Timeframe ?? "1D",
|
||||
InstrumentType = proposal?.InstrumentType ?? request.InstrumentType ?? "Stock",
|
||||
WinRate = proposal?.WinRate ?? 50,
|
||||
VixRegime = proposal?.VixRegime ?? FinlyticCore.Models.Analyzer.VixMarketRegime.Normal,
|
||||
VixValue = proposal?.VixValue ?? 15,
|
||||
Reasoning = proposal?.Reasoning ?? request.Reasoning ?? "User Accepted Trade",
|
||||
EntryZoneMin = proposal?.EntryZoneMin,
|
||||
EntryZoneMax = proposal?.EntryZoneMax,
|
||||
TakeProfitTargets = proposal?.TakeProfitTargets,
|
||||
RiskRewardRatio = proposal?.RiskRewardRatio,
|
||||
MaxLeverage = proposal?.MaxLeverage,
|
||||
TechnicalRationale = proposal?.TechnicalRationale ?? string.Empty,
|
||||
FundamentalRationale = proposal?.FundamentalRationale ?? string.Empty,
|
||||
RiskWarning = proposal?.RiskWarning ?? string.Empty,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
|
||||
ActualEntryPrice = request.ActualEntryPrice > 0 ? request.ActualEntryPrice : (proposal?.EntryPrice ?? request.EntryPrice ?? 0m),
|
||||
PositionSize = request.PositionSize,
|
||||
LeverageUsed = request.LeverageUsed > 0 ? request.LeverageUsed : 1m,
|
||||
EntryFee = request.EntryFee,
|
||||
ExitFee = request.ExitFee,
|
||||
ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow,
|
||||
Quantity = request.Quantity,
|
||||
KnockoutThreshold = request.KnockoutThreshold,
|
||||
IsRecurring = request.IsRecurring
|
||||
};
|
||||
|
||||
newTrade.PnlAbsolute = -(newTrade.EntryFee ?? 0m) - (newTrade.ExitFee ?? 0m);
|
||||
if (newTrade.PositionSize > 0)
|
||||
{
|
||||
newTrade.PnlPercent = (newTrade.PnlAbsolute / newTrade.PositionSize) * 100m;
|
||||
}
|
||||
|
||||
_dbContext.Trades.Add(newTrade);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Successfully created active trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", newTrade.TradeId, request.Isin, newTrade.UserId);
|
||||
return newTrade;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an hourly update for a trade.
|
||||
/// </summary>
|
||||
public async Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trade = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t => t.TradeId == update.TradeId || t.Id.ToString() == update.TradeId, cancellationToken);
|
||||
|
||||
if (trade == null || (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed))
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Cannot add hourly update: Trade {TradeId} not found or not active/proposed.", "TradesChannel", update.TradeId);
|
||||
return;
|
||||
}
|
||||
|
||||
var updateEntity = new TradeHourlyUpdateEntity
|
||||
{
|
||||
TradeId = trade.Id,
|
||||
Recommendation = update.Recommendation,
|
||||
CurrentPrice = update.CurrentPrice,
|
||||
SuggestedStopLoss = update.SuggestedStopLoss,
|
||||
SuggestedTakeProfit = update.SuggestedTakeProfit,
|
||||
VixValue = update.VixValue,
|
||||
Reasoning = update.Reasoning,
|
||||
Timestamp = update.Timestamp
|
||||
};
|
||||
|
||||
_dbContext.TradeHourlyUpdates.Add(updateEntity);
|
||||
|
||||
if (update.SuggestedStopLoss.HasValue && update.SuggestedStopLoss > 0)
|
||||
trade.StopLoss = update.SuggestedStopLoss.Value;
|
||||
if (update.SuggestedTakeProfit.HasValue && update.SuggestedTakeProfit > 0)
|
||||
trade.TakeProfit = update.SuggestedTakeProfit.Value;
|
||||
|
||||
if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (trade.IsGlobalProposal || trade.Status == TradeStatus.Proposed)
|
||||
{
|
||||
trade.Status = TradeStatus.Invalidated;
|
||||
trade.CloseReason = "ProposalInvalidated";
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
trade.Status = TradeStatus.Closed;
|
||||
trade.UserExitPrice = update.CurrentPrice;
|
||||
trade.UserExitTimestamp = DateTime.UtcNow;
|
||||
trade.CloseReason = "AiRecommendationClose";
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
CalculatePnL(trade);
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("[{Channel}] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}",
|
||||
"TradesChannel", update.TradeId, update.Recommendation, update.CurrentPrice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of active trades filtered by optional UserId.
|
||||
/// </summary>
|
||||
public async Task<List<TradeEntity>> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
query = query.Where(t => t.UserId == userId || t.IsGlobalProposal);
|
||||
}
|
||||
|
||||
return await query
|
||||
.Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.Proposed)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of trades filtered by ISIN, status, and optional UserId.
|
||||
/// </summary>
|
||||
public async Task<List<TradeEntity>> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
query = query.Where(t => t.UserId == userId || t.IsGlobalProposal);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(isin))
|
||||
{
|
||||
query = query.Where(t => t.Isin == isin);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<TradeStatus>(status, true, out var parsedStatus))
|
||||
{
|
||||
query = query.Where(t => t.Status == parsedStatus);
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(t => t.CreatedAt).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a trade manually.
|
||||
/// </summary>
|
||||
public async Task<TradeEntity?> CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trade = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken);
|
||||
|
||||
if (trade == null) return null;
|
||||
|
||||
trade.Status = TradeStatus.Closed;
|
||||
trade.UserExitPrice = request.UserExitPrice;
|
||||
trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow;
|
||||
trade.CloseReason = request.CloseReason;
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
CalculatePnL(trade);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("[{Channel}] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)",
|
||||
"TradesChannel", trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent);
|
||||
|
||||
return trade;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a trade proposal.
|
||||
/// </summary>
|
||||
public async Task<TradeEntity?> RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trade = await _dbContext.Trades
|
||||
.FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken);
|
||||
|
||||
if (trade == null) return null;
|
||||
|
||||
trade.Status = TradeStatus.Rejected;
|
||||
trade.CloseReason = request.CloseReason ?? "UserRejected";
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("[{Channel}] Trade {TradeId} rejected by user.", "TradesChannel", trade.TradeId);
|
||||
|
||||
return trade;
|
||||
}
|
||||
|
||||
private static void MapProposalToEntity(TradeProposalDto dto, TradeEntity entity)
|
||||
{
|
||||
entity.AnalysisId = dto.AnalysisId;
|
||||
entity.EventId = dto.EventId;
|
||||
entity.UserId = !string.IsNullOrWhiteSpace(dto.UserId) ? dto.UserId : (entity.UserId ?? "default_user");
|
||||
entity.IsGlobalProposal = dto.IsGlobalProposal;
|
||||
entity.Sector = dto.Sector;
|
||||
entity.Symbol = dto.Symbol;
|
||||
entity.Isin = dto.Isin;
|
||||
entity.CompanyName = dto.CompanyName;
|
||||
|
||||
entity.EntryPrice = dto.EntryPrice;
|
||||
entity.StopLoss = dto.StopLoss;
|
||||
entity.TakeProfit = dto.TakeProfit;
|
||||
entity.SignalType = dto.SignalType;
|
||||
entity.RiskTolerance = dto.RiskTolerance;
|
||||
entity.Timeframe = dto.Timeframe;
|
||||
entity.InstrumentType = dto.InstrumentType;
|
||||
entity.WinRate = dto.WinRate;
|
||||
entity.VixRegime = dto.VixRegime;
|
||||
entity.VixValue = dto.VixValue;
|
||||
entity.TtlMinutes = dto.TtlMinutes;
|
||||
entity.Reasoning = dto.Reasoning;
|
||||
|
||||
entity.EntryZoneMin = dto.EntryZoneMin;
|
||||
entity.EntryZoneMax = dto.EntryZoneMax;
|
||||
entity.TakeProfitTargets = dto.TakeProfitTargets != null ? string.Join(",", dto.TakeProfitTargets) : entity.TakeProfitTargets;
|
||||
entity.RiskRewardRatio = dto.RiskRewardRatio;
|
||||
entity.MaxLeverage = dto.MaxLeverage;
|
||||
entity.TechnicalRationale = dto.TechnicalRationale;
|
||||
entity.FundamentalRationale = dto.FundamentalRationale;
|
||||
entity.RiskWarning = dto.RiskWarning;
|
||||
|
||||
if (dto.ActualEntryPrice.HasValue) entity.ActualEntryPrice = dto.ActualEntryPrice;
|
||||
if (dto.PositionSize.HasValue) entity.PositionSize = dto.PositionSize;
|
||||
if (dto.LeverageUsed.HasValue) entity.LeverageUsed = dto.LeverageUsed;
|
||||
if (dto.EntryFee.HasValue) entity.EntryFee = dto.EntryFee;
|
||||
if (dto.ExitFee.HasValue) entity.ExitFee = dto.ExitFee;
|
||||
if (dto.ExecutionTimestamp.HasValue) entity.ExecutionTimestamp = dto.ExecutionTimestamp;
|
||||
if (dto.Quantity.HasValue) entity.Quantity = dto.Quantity;
|
||||
if (dto.KnockoutThreshold.HasValue) entity.KnockoutThreshold = dto.KnockoutThreshold;
|
||||
entity.IsRecurring = dto.IsRecurring;
|
||||
}
|
||||
|
||||
private static void CalculatePnL(TradeEntity trade)
|
||||
{
|
||||
if (!trade.UserExitPrice.HasValue) return;
|
||||
|
||||
decimal exitPrice = trade.UserExitPrice.Value;
|
||||
decimal entryPrice = trade.ActualEntryPrice.HasValue && trade.ActualEntryPrice.Value > 0m
|
||||
? trade.ActualEntryPrice.Value
|
||||
: trade.EntryPrice;
|
||||
|
||||
if (entryPrice <= 0m) return;
|
||||
|
||||
decimal positionSize = trade.PositionSize.HasValue && trade.PositionSize.Value > 0m
|
||||
? trade.PositionSize.Value
|
||||
: ((trade.Quantity ?? 1m) * entryPrice);
|
||||
|
||||
decimal entryFee = trade.EntryFee ?? 0m;
|
||||
decimal exitFee = trade.ExitFee ?? 0m;
|
||||
decimal totalFees = entryFee + exitFee;
|
||||
|
||||
decimal rawMoveRatio;
|
||||
bool isShort = string.Equals(trade.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trade.SignalType, "SHORT", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isShort)
|
||||
{
|
||||
rawMoveRatio = (entryPrice - exitPrice) / entryPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
rawMoveRatio = (exitPrice - entryPrice) / entryPrice;
|
||||
}
|
||||
|
||||
decimal pnlAbs;
|
||||
if (string.Equals(trade.InstrumentType, "KnockOut", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trade.InstrumentType, "Certificate", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trade.InstrumentType, "Option", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
pnlAbs = (rawMoveRatio * positionSize) - totalFees;
|
||||
}
|
||||
else
|
||||
{
|
||||
decimal leverage = trade.LeverageUsed > 0m ? trade.LeverageUsed.Value : 1m;
|
||||
pnlAbs = (rawMoveRatio * positionSize * leverage) - totalFees;
|
||||
}
|
||||
|
||||
trade.PnlAbsolute = Math.Round(pnlAbs, 4);
|
||||
trade.PnlPercent = positionSize > 0m
|
||||
? Math.Round((pnlAbs / positionSize) * 100.0m, 2)
|
||||
: 0m;
|
||||
|
||||
trade.IsWin = pnlAbs > 0m;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user