feat(fundamentals): multi-ticker DB caching, parallel html scraper, and frontend mappings

This commit is contained in:
2026-08-16 14:05:57 +02:00
parent b0f8d4b78b
commit 2ba54e8057
17 changed files with 1791 additions and 563 deletions
@@ -154,58 +154,63 @@ public class TradeLifecycleService : ITradeLifecycleService
{
string targetUserId = !string.IsNullOrWhiteSpace(request.UserId) ? request.UserId : "default_user";
var existingTrade = await _dbContext.Trades
// 1. Prüfen, ob DIESER spezifische Nutzer diesen Trade/AnalysisId bereits als aktiven Trade angenommen hat
var userExistingTrade = await _dbContext.Trades
.FirstOrDefaultAsync(t =>
(!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) ||
(!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId), cancellationToken);
!t.IsGlobalProposal &&
t.UserId == targetUserId &&
((!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) ||
(!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId)),
cancellationToken);
if (existingTrade != null)
if (userExistingTrade != null)
{
if (existingTrade.Status == TradeStatus.Closed)
if (userExistingTrade.Status == TradeStatus.Closed)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Refused to accept trade {TradeId} because its status is CLOSED", existingTrade.TradeId);
await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Refused to accept trade {TradeId} because user's trade is already CLOSED", userExistingTrade.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.DerivativeIsin)) existingTrade.DerivativeIsin = request.DerivativeIsin;
if (!string.IsNullOrWhiteSpace(request.Reasoning)) existingTrade.Reasoning = request.Reasoning;
// Bestehenden User-Trade mit neuen Parametern aktualisieren
if (request.ActualEntryPrice > 0) userExistingTrade.ActualEntryPrice = request.ActualEntryPrice;
if (request.EntryPrice > 0) userExistingTrade.EntryPrice = request.EntryPrice.Value;
if (request.PositionSize > 0) userExistingTrade.PositionSize = request.PositionSize;
if (request.LeverageUsed > 0) userExistingTrade.LeverageUsed = request.LeverageUsed;
if (request.Quantity > 0) userExistingTrade.Quantity = request.Quantity;
if (request.EntryFee.HasValue) userExistingTrade.EntryFee = request.EntryFee;
if (request.ExitFee.HasValue) userExistingTrade.ExitFee = request.ExitFee;
if (request.StopLoss > 0) userExistingTrade.StopLoss = request.StopLoss.Value;
if (request.TakeProfit > 0) userExistingTrade.TakeProfit = request.TakeProfit.Value;
if (request.KnockoutThreshold > 0) userExistingTrade.KnockoutThreshold = request.KnockoutThreshold;
if (!string.IsNullOrWhiteSpace(request.Timeframe)) userExistingTrade.Timeframe = request.Timeframe;
if (!string.IsNullOrWhiteSpace(request.DerivativeIsin)) userExistingTrade.DerivativeIsin = request.DerivativeIsin;
if (!string.IsNullOrWhiteSpace(request.Reasoning)) userExistingTrade.Reasoning = request.Reasoning;
existingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow;
userExistingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow;
existingTrade.PnlAbsolute = -(existingTrade.EntryFee ?? 0m) - (existingTrade.ExitFee ?? 0m);
if (existingTrade.PositionSize > 0)
userExistingTrade.PnlAbsolute = -(userExistingTrade.EntryFee ?? 0m) - (userExistingTrade.ExitFee ?? 0m);
if (userExistingTrade.PositionSize > 0)
{
existingTrade.PnlPercent = (existingTrade.PnlAbsolute / existingTrade.PositionSize) * 100m;
userExistingTrade.PnlPercent = (userExistingTrade.PnlAbsolute / userExistingTrade.PositionSize) * 100m;
}
_dbContext.Trades.Update(existingTrade);
_dbContext.Trades.Update(userExistingTrade);
await _dbContext.SaveChangesAsync(cancellationToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully ACCEPTED and UPDATED trade {TradeId} for ISIN {Isin}, UserId: {UserId}", existingTrade.TradeId, existingTrade.Isin, existingTrade.UserId);
return existingTrade;
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully UPDATED existing trade {TradeId} for ISIN {Isin}, UserId: {UserId}", userExistingTrade.TradeId, userExistingTrade.Isin, userExistingTrade.UserId);
return userExistingTrade;
}
// 2. Globalen Trade-Vorschlag finden (dieser bleibt unverändert in der DB, damit andere Nutzer ihn ebenfalls annehmen können)
var proposal = await _dbContext.Trades
.FirstOrDefaultAsync(t => t.IsGlobalProposal &&
(!string.IsNullOrEmpty(request.AnalysisId) ? t.AnalysisId == request.AnalysisId : t.Isin == request.Isin),
.FirstOrDefaultAsync(t =>
(t.IsGlobalProposal || t.Status == TradeStatus.Proposed) &&
((!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId) ||
(!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) ||
(!string.IsNullOrEmpty(request.Isin) && t.Isin == request.Isin)),
cancellationToken);
var targetTradeId = !string.IsNullOrWhiteSpace(request.TradeId) ? request.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant());
var targetTradeId = "TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant();
var newTrade = new TradeEntity
{
@@ -248,9 +253,10 @@ public class TradeLifecycleService : ITradeLifecycleService
EntryFee = request.EntryFee,
ExitFee = request.ExitFee,
ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow,
Quantity = request.Quantity,
Quantity = request.Quantity > 0 ? request.Quantity : 1m,
KnockoutThreshold = request.KnockoutThreshold,
IsRecurring = request.IsRecurring
IsRecurring = request.IsRecurring,
DerivativeProductCategories = proposal?.DerivativeProductCategories != null ? new List<string>(proposal.DerivativeProductCategories) : new List<string>()
};
newTrade.PnlAbsolute = -(newTrade.EntryFee ?? 0m) - (newTrade.ExitFee ?? 0m);
@@ -262,58 +268,69 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Add(newTrade);
await _dbContext.SaveChangesAsync(cancellationToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully created active trade {TradeId} for ISIN {Isin}, UserId: {UserId}", newTrade.TradeId, request.Isin, newTrade.UserId);
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully CREATED individual active trade {TradeId} for ISIN {Isin}, UserId: {UserId} from proposal {AnalysisId}",
newTrade.TradeId, newTrade.Isin, newTrade.UserId, newTrade.AnalysisId);
return newTrade;
}
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);
var matchedTrades = await _dbContext.Trades
.Where(t => t.TradeId == update.TradeId || (t.AnalysisId != null && t.AnalysisId == update.TradeId) || t.Id.ToString() == update.TradeId)
.ToListAsync(cancellationToken);
if (trade == null || (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed))
if (matchedTrades.Count == 0)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Cannot add hourly update: Trade {TradeId} not found or not active/proposed.", update.TradeId);
await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Cannot add hourly update: No active or proposed trades found for identifier {TradeId}.", update.TradeId);
return;
}
var updateEntity = new TradeHourlyUpdateEntity
foreach (var trade in matchedTrades)
{
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)
if (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed)
{
trade.Status = TradeStatus.Invalidated;
trade.CloseReason = "ProposalInvalidated";
trade.ClosedAt = DateTime.UtcNow;
continue;
}
else
var updateEntity = new TradeHourlyUpdateEntity
{
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.",
trade.TradeId, update.Reasoning);
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
{
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Active trade {TradeId} (UserId: {UserId}) received Close recommendation ({Reasoning}). Trade kept Active for user action.",
trade.TradeId, trade.UserId, update.Reasoning);
}
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}",
update.TradeId, update.Recommendation, update.CurrentPrice);
await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Added hourly update across {Count} matched trades for identifier {TradeId}. Rec: {Rec}, Price: {Price}",
matchedTrades.Count, update.TradeId, update.Recommendation, update.CurrentPrice);
}
public async Task<List<TradeEntity>> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default)