261 lines
11 KiB
C#
261 lines
11 KiB
C#
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using FinlyticCore.Dtos.Bot;
|
|
using FinlyticCore.Dtos.News;
|
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
using FinlyticCore.Dtos.Trading;
|
|
|
|
namespace FinlyticNotify.Services;
|
|
|
|
/// <summary>
|
|
/// Service interface for transforming trading and news events into structured ntfy push notifications.
|
|
/// </summary>
|
|
public interface INotificationFormatter
|
|
{
|
|
/// <summary>
|
|
/// Formats a new trade proposal into a high-priority opportunity notification.
|
|
/// </summary>
|
|
NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic);
|
|
|
|
/// <summary>
|
|
/// Formats an active trade lifecycle change into a user-specific status notification.
|
|
/// </summary>
|
|
NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic);
|
|
|
|
/// <summary>
|
|
/// Formats an automated paper-trading bot execution event into a notification.
|
|
/// </summary>
|
|
NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic);
|
|
|
|
/// <summary>
|
|
/// Formats an analyzed news article with sentiment evaluation into a push notification.
|
|
/// </summary>
|
|
NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Implementation of <see cref="INotificationFormatter"/> that creates emoji-rich Markdown messages
|
|
/// formatted for the ntfy mobile/web applications.
|
|
/// </summary>
|
|
public class NotificationFormatter : INotificationFormatter
|
|
{
|
|
/// <inheritdoc />
|
|
public NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic)
|
|
{
|
|
bool isBuy = proposal.Direction == SignalDirection.Buy;
|
|
string dirEmoji = isBuy ? "🟢" : "🔴";
|
|
string dirText = isBuy ? "Long" : "Short";
|
|
string title = $"{dirEmoji} Neuer Trade-Vorschlag: {proposal.Symbol} ({dirText})";
|
|
|
|
var tags = new List<string>
|
|
{
|
|
isBuy ? "chart_with_upwards_trend" : "chart_with_downwards_trend",
|
|
"moneybag",
|
|
"dart"
|
|
};
|
|
|
|
int priority = proposal.CompositeScore >= 80m ? 4 : 3;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"**Strategie:** {proposal.StrategyKey} | **Score:** {proposal.CompositeScore:F1}/100");
|
|
sb.AppendLine($"**Einstieg:** {proposal.EntryPrice:F2} €");
|
|
sb.AppendLine($"**Stop-Loss:** {proposal.InvalidationPrice:F2} €");
|
|
|
|
var tp1 = proposal.ExitPlan?.TakeProfitStages?.FirstOrDefault();
|
|
if (tp1 != null)
|
|
{
|
|
sb.AppendLine($"**Ziel (TP1):** {tp1.TargetPrice:F2} € ({tp1.Description})");
|
|
}
|
|
|
|
if (proposal.SelectedDerivative != null)
|
|
{
|
|
sb.AppendLine($"**Knock-Out:** {proposal.SelectedDerivative.Issuer} ({proposal.SelectedDerivative.OptionType}, Hebel: {proposal.SelectedDerivative.Leverage:F1}x)");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(proposal.AiValidation?.ThesisSummary))
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine($"**KI-These:** {proposal.AiValidation.ThesisSummary}");
|
|
}
|
|
|
|
return new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: title,
|
|
Message: sb.ToString().TrimEnd(),
|
|
Priority: priority,
|
|
Tags: tags
|
|
);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic)
|
|
{
|
|
string dirText = trade.Direction == SignalDirection.Buy ? "Long" : "Short";
|
|
|
|
return trade.Status switch
|
|
{
|
|
TradeStatus.Active or TradeStatus.Proposed => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"⚡ Trade aktiv: {trade.Symbol} ({dirText})",
|
|
Message: $"**Buy-In:** {trade.AverageBuyIn:F2} € | **Menge:** {trade.TotalQuantity:F2}\n" +
|
|
$"**Initialer Stop-Loss:** {trade.InitialStopLoss:F2} €\n" +
|
|
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} €",
|
|
Priority: 3,
|
|
Tags: ["zap", "white_check_mark"]
|
|
),
|
|
|
|
TradeStatus.Tp1Hit => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"🎯 Teilgewinn erreicht (TP1): {trade.Symbol} (+{trade.UnrealizedPnlPercent:F1}%)",
|
|
Message: $"**Gewinn:** +{trade.UnrealizedPnlEur:F2} € (+{trade.UnrealizedPnlPercent:F1}%)\n" +
|
|
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
|
|
$"**Neuer Stop-Loss:** {trade.CurrentStopLoss:F2} € (Break-Even gesichert)",
|
|
Priority: 4,
|
|
Tags: ["tada", "dart", "chart_with_upwards_trend"]
|
|
),
|
|
|
|
TradeStatus.Tp2Hit => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"🏆 Vollziel erreicht (TP2): {trade.Symbol} (+{trade.RealizedPnlEur:F2} €)",
|
|
Message: $"**Realisierter Gewinn:** +{trade.RealizedPnlEur:F2} €\n" +
|
|
$"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
|
|
$"**Status:** Trade erfolgreich mit Maximalziel abgeschlossen!",
|
|
Priority: 4,
|
|
Tags: ["trophy", "money_with_wings", "star2"]
|
|
),
|
|
|
|
TradeStatus.StoppedOut => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"🛑 Stop-Loss ausgelöst: {trade.Symbol} ({trade.RealizedPnlEur:F2} €)",
|
|
Message: $"**Verlust:** {trade.RealizedPnlEur:F2} €\n" +
|
|
$"**Ausstiegskurs:** {trade.CurrentPrice:F2} € (Stop war bei {trade.CurrentStopLoss:F2} €)\n" +
|
|
$"**Status:** Position durch Stop-Loss risikokontrolliert geschlossen.",
|
|
Priority: 4,
|
|
Tags: ["octagonal_sign", "warning", "shield"]
|
|
),
|
|
|
|
TradeStatus.Closed => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"🏁 Trade geschlossen: {trade.Symbol} (G/V: {trade.RealizedPnlEur:F2} €)",
|
|
Message: $"**Realisierter G/V:** {trade.RealizedPnlEur:F2} €\n" +
|
|
$"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)",
|
|
Priority: 3,
|
|
Tags: ["checkered_flag", "information_source"]
|
|
),
|
|
|
|
_ => new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: $"🛡️ Trade Update: {trade.Symbol} ({trade.Status})",
|
|
Message: $"**Aktueller Stop-Loss:** {trade.CurrentStopLoss:F2} €\n" +
|
|
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
|
|
$"**Unrealisierter G/V:** {trade.UnrealizedPnlEur:F2} € ({trade.UnrealizedPnlPercent:F1}%)",
|
|
Priority: 2,
|
|
Tags: ["shield", "chart"]
|
|
)
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic)
|
|
{
|
|
string dirText = botTrade.Direction == SignalDirection.Buy ? "Long" : "Short";
|
|
string title = $"🤖 Bot Trade [{botTrade.Status}]: {botTrade.Symbol} ({dirText})";
|
|
|
|
var tags = new List<string> { "robot", "chart" };
|
|
if (botTrade.Status == BotPositionStatus.Tp1Hit || botTrade.Status == BotPositionStatus.Tp2Hit) tags.Add("dart");
|
|
if (botTrade.Status == BotPositionStatus.StoppedOut) tags.Add("warning");
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"**Venue:** {botTrade.Venue} | **Status:** {botTrade.Status}");
|
|
sb.AppendLine($"**Buy-In:** {botTrade.AverageBuyIn:F2} € | **Menge:** {botTrade.FilledQuantity:F2}");
|
|
sb.AppendLine($"**Stop-Loss:** {botTrade.CurrentStopLoss:F2} €");
|
|
sb.AppendLine($"**Aktueller Kurs:** {botTrade.CurrentPrice:F2} €");
|
|
|
|
if (botTrade.Status == BotPositionStatus.Closed || botTrade.Status == BotPositionStatus.StoppedOut || botTrade.Status == BotPositionStatus.Tp2Hit)
|
|
{
|
|
sb.AppendLine($"**Realisierter G/V:** {botTrade.RealizedPnlEur:F2} €");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($"**Unrealisierter G/V:** {botTrade.UnrealizedPnlEur:F2} €");
|
|
}
|
|
|
|
return new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: title,
|
|
Message: sb.ToString().TrimEnd(),
|
|
Priority: 3,
|
|
Tags: tags
|
|
);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic)
|
|
{
|
|
string sentimentLabel = (article.Sentiment ?? "NEUTRAL").ToUpperInvariant();
|
|
double score = article.SentimentScore ?? 0.0;
|
|
double confidence = article.Confidence ?? 0.0;
|
|
|
|
string sentimentEmoji = sentimentLabel switch
|
|
{
|
|
"POSITIVE" => "🟢",
|
|
"NEGATIVE" => "🔴",
|
|
_ => "⚪"
|
|
};
|
|
|
|
string primaryAsset = article.MatchedAssets?.FirstOrDefault()?.Name
|
|
?? article.MatchedAssets?.FirstOrDefault()?.Isin
|
|
?? "Markt";
|
|
|
|
string title = $"{sentimentEmoji} News ({sentimentLabel}): {primaryAsset}";
|
|
|
|
var tags = new List<string> { "newspaper" };
|
|
if (sentimentLabel == "POSITIVE")
|
|
{
|
|
tags.Add("chart_with_upwards_trend");
|
|
tags.Add("tada");
|
|
}
|
|
else if (sentimentLabel == "NEGATIVE")
|
|
{
|
|
tags.Add("chart_with_downwards_trend");
|
|
tags.Add("warning");
|
|
}
|
|
else
|
|
{
|
|
tags.Add("information_source");
|
|
}
|
|
|
|
int priority = (confidence >= 0.8 && Math.Abs(score) >= 0.6) ? 4 : 3;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"**{article.Title}**");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"**Sentiment:** {sentimentLabel} (Score: {score:+0.00;-0.00;0.00} | Konfidenz: {confidence:P0})");
|
|
|
|
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
|
|
{
|
|
var assetList = string.Join(", ", article.MatchedAssets.Select(a => $"{a.Name} ({a.Isin})"));
|
|
sb.AppendLine($"**Assets:** {assetList}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(article.Summary))
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine($"_{article.Summary}_");
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine($"**Veröffentlicht:** {article.PublishedAt:dd.MM.yyyy HH:mm} UTC");
|
|
|
|
return new NtfyNotification(
|
|
Topic: targetTopic,
|
|
Title: title,
|
|
Message: sb.ToString().TrimEnd(),
|
|
Priority: priority,
|
|
Tags: tags,
|
|
ClickUrl: !string.IsNullOrWhiteSpace(article.SourceUrl) ? article.SourceUrl : null
|
|
);
|
|
}
|
|
}
|