Compare commits

..

6 Commits

45 changed files with 4019 additions and 100 deletions
+1
View File
@@ -23,6 +23,7 @@ $images = @(
"finlyticengine",
"finlyticsimulation",
"finlyticbot",
"finlyticnotify",
"finlyticbackend"
)
+28
View File
@@ -29,6 +29,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "Fin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify", "FinlyticNotify\FinlyticNotify.csproj", "{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify.Tests", "FinlyticNotify.Tests\FinlyticNotify.Tests.csproj", "{6E54FE48-A814-469C-B2E4-67C0EB575A9E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -175,6 +179,30 @@ Global
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.Build.0 = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+15 -6
View File
@@ -221,8 +221,10 @@ public class AssetsDbService : IAssetsDbService
HasSubtypeChanges(existingEntity, dto))
{
existingEntity.Name = dto.Name;
existingEntity.Type = dto.Type;
existingEntity.InstrumentCategory = dto.InstrumentCategory;
existingEntity.Type = dto.Type ?? existingEntity.Type ?? "stock";
existingEntity.InstrumentCategory = !string.IsNullOrWhiteSpace(dto.InstrumentCategory)
? dto.InstrumentCategory
: (existingEntity.InstrumentCategory ?? dto.Type ?? "stock");
existingEntity.HasCfd = dto.HasCfd;
existingEntity.LastUpdatedAt = now;
existingEntity.Tags = mappedTags;
@@ -242,6 +244,13 @@ public class AssetsDbService : IAssetsDbService
private static AssetEntity MapDtoToEntity(TradeRepublicAsset dto)
{
static string ResolveCategory(string? category, string? fallbackType, string defaultCategory)
{
if (!string.IsNullOrWhiteSpace(category)) return category;
if (!string.IsNullOrWhiteSpace(fallbackType)) return fallbackType;
return defaultCategory;
}
return dto switch
{
TradeRepublicStock stock => new StockEntity
@@ -249,7 +258,7 @@ public class AssetsDbService : IAssetsDbService
Isin = stock.Isin,
Name = stock.Name,
Type = stock.Type,
InstrumentCategory = stock.InstrumentCategory,
InstrumentCategory = ResolveCategory(stock.InstrumentCategory, stock.Type, "stock"),
HasCfd = stock.HasCfd,
DerivativeProductCategories = stock.DerivativeProductCategories?.ToList() ?? new List<string>()
},
@@ -258,7 +267,7 @@ public class AssetsDbService : IAssetsDbService
Isin = etf.Isin,
Name = etf.Name,
Type = etf.Type,
InstrumentCategory = etf.InstrumentCategory,
InstrumentCategory = ResolveCategory(etf.InstrumentCategory, etf.Type, "fund"),
HasCfd = etf.HasCfd,
DerivativeProductCategories = etf.DerivativeProductCategories?.ToList() ?? new List<string>()
},
@@ -267,7 +276,7 @@ public class AssetsDbService : IAssetsDbService
Isin = syn.Isin,
Name = syn.Name,
Type = syn.Type,
InstrumentCategory = syn.InstrumentCategory,
InstrumentCategory = ResolveCategory(syn.InstrumentCategory, syn.Type, "synthetic"),
HasCfd = syn.HasCfd,
DerivativeProductCategories = syn.DerivativeProductCategories?.ToList() ?? new List<string>()
},
@@ -276,7 +285,7 @@ public class AssetsDbService : IAssetsDbService
Isin = dto.Isin,
Name = dto.Name,
Type = dto.Type,
InstrumentCategory = dto.InstrumentCategory,
InstrumentCategory = ResolveCategory(dto.InstrumentCategory, dto.Type, "stock"),
HasCfd = dto.HasCfd
}
};
@@ -83,6 +83,7 @@ public class AdminSettingsController : ControllerBase
["FinlyticAnalyzer"] = "engine",
["FinlyticTrades"] = "engine",
["FinlyticBot"] = "bot",
["FinlyticNotify"] = "notify",
// FinlyticSimulation was previously missing from this map entirely, so its settings
// (SimulationSettingKeys: slippage/fee defaults, matrix-recompute schedule, etc.) never showed up
// in the admin UI's settings screen even though the sim_settings_GetAll/Update RPC channels exist.
@@ -209,6 +210,7 @@ public class AdminSettingsController : ControllerBase
("FinlyticFundamentals", $"{MqttTopics.Channels.HealthPing}/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
("FinlyticEngine", $"{MqttTopics.Channels.HealthPing}/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
("FinlyticBot", $"{MqttTopics.Channels.HealthPing}/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
("FinlyticNotify", $"{MqttTopics.Channels.HealthPing}/FinlyticNotify", "ntfy Push Notifications", "PostgreSQL / Mosquitto"),
};
var results = new List<ServiceHealthStatusDto>
+1 -1
View File
@@ -149,7 +149,7 @@ app.UseExceptionHandler();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
KnownNetworks = { },
KnownIPNetworks = { },
KnownProxies = { }
});
@@ -70,6 +70,7 @@ public class SystemHealthBackgroundService : BackgroundService
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
("FinlyticEngine", "health_Ping/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
("FinlyticBot", "health_Ping/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
("FinlyticNotify", "health_Ping/FinlyticNotify", "ntfy Push Notifications", "PostgreSQL / Mosquitto"),
};
var results = new List<ServiceHealthStatusDto>
+26
View File
@@ -9,6 +9,7 @@ using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Services;
using FinlyticBackend.Settings;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Logging;
using FinlyticCore.Dtos.News;
@@ -104,6 +105,11 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetAggregatedFavorites),
HandleGetAggregatedFavoritesRpcAsync);
// Username lookup RPC Endpoint for FinlyticNotify
await SubscribeRpcAsync<UserIdRequest, string?>(
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetUsername),
HandleGetUsernameRpcAsync);
// FinlyticBackend previously never broadcast its OWN structured logs at all (it only relayed other
// services' logs received on MqttTopics.LogsWildcard, subscribed above) - it never used
// IFinlyticLogger<T>, so FinlyticLogBroadcaster.Broadcast was never invoked for anything happening
@@ -286,4 +292,24 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
"[BackendMqttBridge] Responded to backend_GetAggregatedFavorites with {Count} unique ISINs. [CorrelationId: {CorrelationId}]", isins.Count, correlationId);
return isins;
}
private async Task<string?> HandleGetUsernameRpcAsync(UserIdRequest? req, string correlationId)
{
if (req == null || req.UserId == Guid.Empty) return null;
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == req.UserId);
if (user == null) return null;
string username = !string.IsNullOrWhiteSpace(user.FullName)
? user.FullName.Trim().ToLowerInvariant().Replace(" ", "_")
: user.Email.Split('@')[0].Trim().ToLowerInvariant();
return username;
}
}
+7
View File
@@ -245,6 +245,13 @@ public record CreateManualTradeRequest(
[property: JsonPropertyName("fee")] decimal Fee = 0m
);
/// <summary>
/// Generic request payload carrying a user GUID to resolve user identity across microservices.
/// </summary>
public record UserIdRequest(
[property: JsonPropertyName("userId")] Guid UserId
);
/// <summary>
/// Machine-readable classification of a server-side RPC fault, carried by <see cref="RpcErrorResponse"/> so a
/// caller can react to the specific failure mode instead of only learning "something went wrong" (or, before
@@ -27,7 +27,13 @@ public record TradeRepublicAsset
[JsonPropertyName("isin")] public string Isin { get; init; } = "";
[JsonPropertyName("name")] public string Name { get; init; } = "";
[JsonPropertyName("type")] public string Type { get; init; } = "";
[JsonPropertyName("instrumentCategory")] public string InstrumentCategory { get; init; } = "";
private readonly string _instrumentCategory = "";
[JsonPropertyName("instrumentCategory")]
public string InstrumentCategory
{
get => _instrumentCategory;
init => _instrumentCategory = value ?? "";
}
[JsonPropertyName("hasCfd")] public bool HasCfd { get; init; }
[JsonPropertyName("imageId")] public string? ImageId { get; init; }
@@ -108,6 +108,7 @@ public record TradeFillDto(
public record ActiveTradeDto(
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("derivativeIsin")] string? DerivativeIsin,
+13
View File
@@ -279,6 +279,14 @@ public static class MqttTopics
/// <summary>Served by FinlyticBot: applies dynamic setting updates for the service.</summary>
public const string BotSettingsUpdate = "bot_settings_Update";
// ---- FinlyticNotify ----
/// <summary>Served by FinlyticNotify: returns all dynamic settings for the service.</summary>
public const string NotifySettingsGetAll = "notify_settings_GetAll";
/// <summary>Served by FinlyticNotify: applies dynamic setting updates for the service.</summary>
public const string NotifySettingsUpdate = "notify_settings_Update";
// ---- FinlyticBackend ----
/// <summary>
@@ -286,6 +294,11 @@ public static class MqttTopics
/// even though FinlyticBackend is outside this refactor's scope, so no future service hardcodes it again.
/// </summary>
public const string BackendGetAggregatedFavorites = "backend_GetAggregatedFavorites";
/// <summary>
/// Served by FinlyticBackend: resolves and returns the clean username for a given UserId GUID.
/// </summary>
public const string BackendGetUsername = "backend_GetUsername";
}
// ---------------------------------------------------------------------------------------------------
@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Settings;
using FinlyticEngine.Tests.TestSupport;
using Xunit;
namespace FinlyticEngine.Tests.Services.Scoring;
public class CompositeOpportunityScorerV2Tests
{
private readonly FakeSettingsService _settings = new();
private readonly FakeFinlyticLogger<CompositeOpportunityScorerV2> _logger = new();
private readonly CompositeOpportunityScorerV2 _scorer;
public CompositeOpportunityScorerV2Tests()
{
_scorer = new CompositeOpportunityScorerV2(_settings, _logger);
}
private static StrategyResultDto CreateSetup(SignalDirection direction, decimal qualityScore = 85m)
{
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: "US0378331005",
Symbol: "AAPL",
Timeframe: "15m",
StrategyKey: "TrendPullbackFvg",
StrategyName: "Trend Pullback FVG",
Direction: direction,
QualityScore: qualityScore,
CurrentPrice: 150m,
EntryPrice: 150m,
InvalidationPrice: direction == SignalDirection.Buy ? 145m : 155m,
CurrentAtr: 2.5m,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: TestData.SimpleExitPlan(),
TechnicalRationale: "Test setup",
TriggeringPatterns: [],
IndicatorSnapshot: new Dictionary<string, decimal>(),
CreatedAt: DateTime.UtcNow,
ExpiresAt: DateTime.UtcNow.AddHours(4),
IsTopPick: true,
Rating: "A"
);
}
[Fact]
public async Task CalculateCompositeScoreAsync_BuyDirection_BullishFundamentals_ScoresHigh()
{
// Arrange
var setup = CreateSetup(SignalDirection.Buy, qualityScore: 85m);
var sentiment = new IsinSentimentSummaryDto
{
Isin = "US0378331005",
CurrentSummary = new IsinCurrentSummary
{
SentimentLabel = "POSITIVE",
CompoundScore = 0.8,
TotalArticlesAnalyzed = 15,
PositiveArticles = 12,
NegativeArticles = 1,
NeutralArticles = 2,
Trend = "IMPROVING",
KeyHighlight = "Strong quarterly earnings surprise"
}
};
var fundamentals = new AssetFundamentalsDto
{
Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Apple Inc." },
Fundamentals = new FundamentalDataDto
{
MarketCap = 3000000000000m,
ForwardPe = 18m, // Low PE -> +10
TrailingPe = 22m,
PriceToBook = 10m,
ReturnOnEquity = 0.25m, // High ROE -> +10
TotalRevenue = 1000000000m,
RevenueGrowthYoY = 0.15m,
OperatingIncome = 300000000m,
NetIncome = 250000000m,
DebtToEquity = 1.2m,
FreeCashFlow = 200000000m,
ConsensusRating = "Strong_Buy", // Strong Buy -> +10
PriceTargetMean = 180m,
ShortPercentOfFloat = 0.02m
},
Events =
[
new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) },
new CorporateEventDto { Type = "Dividend", Date = DateTime.UtcNow.AddDays(30) }
],
LastUpdatedAt = DateTime.UtcNow
};
// Act
var result = await _scorer.CalculateCompositeScoreAsync(setup, sentiment, fundamentals);
// Assert
Assert.True(result.FundamentalScore >= 80m, $"Expected FundamentalScore >= 80, but got {result.FundamentalScore}");
Assert.True(result.SentimentScore >= 85m, $"Expected SentimentScore >= 85, but got {result.SentimentScore}");
Assert.True(result.CompositeScore >= 80m, $"Expected CompositeScore >= 80, but got {result.CompositeScore}");
}
[Fact]
public async Task CalculateCompositeScoreAsync_BuyDirection_BearishFundamentals_ScoresLow()
{
// Arrange: Buy setup with awful fundamentals
var setup = CreateSetup(SignalDirection.Buy, qualityScore: 85m);
var fundamentals = new AssetFundamentalsDto
{
Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Loss Making Corp" },
Fundamentals = new FundamentalDataDto
{
MarketCap = 1000000000m,
ForwardPe = 65m, // High PE -> -10
TrailingPe = 70m,
PriceToBook = 5m,
ReturnOnEquity = -0.10m, // Negative ROE -> -15
TotalRevenue = 100000000m,
RevenueGrowthYoY = -0.20m,
OperatingIncome = -20000000m,
NetIncome = -25000000m,
DebtToEquity = 3.5m, // High debt -> -10
FreeCashFlow = -30000000m,
ConsensusRating = "Underperform", // Sell/Underperform -> -15
PriceTargetMean = 80m,
ShortPercentOfFloat = 0.15m
},
Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }],
LastUpdatedAt = DateTime.UtcNow
};
// Act
var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals);
// Assert
Assert.True(result.FundamentalScore <= 15m, $"Expected FundamentalScore <= 15 for bad fundamentals on Buy, but got {result.FundamentalScore}");
}
[Fact]
public async Task CalculateCompositeScoreAsync_SellDirection_BearishFundamentals_ScoresHigh()
{
// Arrange: Sell setup on an overvalued, unprofitable company with Sell rating & negative sentiment
var setup = CreateSetup(SignalDirection.Sell, qualityScore: 85m);
var sentiment = new IsinSentimentSummaryDto
{
Isin = "US0378331005",
CurrentSummary = new IsinCurrentSummary
{
SentimentLabel = "NEGATIVE",
CompoundScore = -0.8, // Strong negative sentiment -> should score 90 for Sell!
TotalArticlesAnalyzed = 15,
PositiveArticles = 1,
NegativeArticles = 12,
NeutralArticles = 2,
Trend = "DETERIORATING",
KeyHighlight = "Investigation launched and guidance slashed"
}
};
var fundamentals = new AssetFundamentalsDto
{
Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Struggling Tech Corp" },
Fundamentals = new FundamentalDataDto
{
MarketCap = 1000000000m,
ForwardPe = 60m, // High PE -> +12 for Short
TrailingPe = 70m,
PriceToBook = 5m,
ReturnOnEquity = -0.15m, // Negative ROE -> +15 for Short
TotalRevenue = 100000000m,
RevenueGrowthYoY = -0.30m,
OperatingIncome = -20000000m,
NetIncome = -25000000m,
DebtToEquity = 3.0m, // High debt -> +10 for Short
FreeCashFlow = -30000000m,
ConsensusRating = "Underperform", // Sell/Underperform -> +15 for Short
PriceTargetMean = 60m,
ShortPercentOfFloat = 0.10m // Moderate short interest -> +5 for Short
},
Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }],
LastUpdatedAt = DateTime.UtcNow
};
// Act
var result = await _scorer.CalculateCompositeScoreAsync(setup, sentiment, fundamentals);
// Assert
Assert.True(result.FundamentalScore >= 90m, $"Expected FundamentalScore >= 90 for ideal short fundamentals, but got {result.FundamentalScore}");
Assert.True(result.SentimentScore >= 85m, $"Expected SentimentScore >= 85 for bearish sentiment on Sell, but got {result.SentimentScore}");
Assert.True(result.CompositeScore >= 85m, $"Expected CompositeScore >= 85 for ideal short setup, but got {result.CompositeScore}");
}
[Fact]
public async Task CalculateCompositeScoreAsync_SellDirection_BullishFundamentals_ScoresLow()
{
// Arrange: Sell setup on a high quality, profitable, cheap company
var setup = CreateSetup(SignalDirection.Sell, qualityScore: 85m);
var fundamentals = new AssetFundamentalsDto
{
Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "High Quality Value Inc." },
Fundamentals = new FundamentalDataDto
{
MarketCap = 3000000000000m,
ForwardPe = 12m, // Cheap PE -> -12 for Short (hard to fall further)
TrailingPe = 14m,
PriceToBook = 2m,
ReturnOnEquity = 0.35m, // High ROE -> -12 for Short (cash cow resilience)
TotalRevenue = 1000000000m,
RevenueGrowthYoY = 0.20m,
OperatingIncome = 300000000m,
NetIncome = 250000000m,
DebtToEquity = 0.5m,
FreeCashFlow = 200000000m,
ConsensusRating = "Strong_Buy", // Strong buy -> -15 for Short
PriceTargetMean = 220m,
ShortPercentOfFloat = 0.01m
},
Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }],
LastUpdatedAt = DateTime.UtcNow
};
// Act
var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals);
// Assert
Assert.True(result.FundamentalScore <= 20m, $"Expected FundamentalScore <= 20 for shorting a healthy company, but got {result.FundamentalScore}");
}
[Fact]
public async Task CalculateCompositeScoreAsync_SellDirection_HighShortFloat_AppliesSqueezeRiskPenalty()
{
// Arrange: Sell setup with excessive short float (> 25%) indicating short squeeze risk
var setup = CreateSetup(SignalDirection.Sell, qualityScore: 80m);
var fundamentals = new AssetFundamentalsDto
{
Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Heavily Shorted Corp" },
Fundamentals = new FundamentalDataDto
{
MarketCap = 1000000000m,
ForwardPe = 30m,
TrailingPe = 35m,
PriceToBook = 3m,
ReturnOnEquity = 0.05m,
TotalRevenue = 100000000m,
RevenueGrowthYoY = 0.02m,
OperatingIncome = 5000000m,
NetIncome = 3000000m,
DebtToEquity = 1.0m,
FreeCashFlow = 2000000m,
ConsensusRating = "Hold",
PriceTargetMean = 100m,
ShortPercentOfFloat = 0.35m // 35% float shorted -> Squeeze danger!
},
Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }],
LastUpdatedAt = DateTime.UtcNow
};
// Act
var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals);
// Base score: 50 - 10 (squeeze penalty) = 40
Assert.Equal(40m, result.FundamentalScore);
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ builder.Services.AddSingleton<IEngineRpcClient>(sp => sp.GetRequiredService<Engi
builder.Services.AddHostedService(sp => sp.GetRequiredService<EngineMqttClient>());
// 5. Register Engine Domain Services
builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorer>();
// builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorer>(); // V1 Fallback
builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorerV2>(); // V2 Bidirectional Active
builder.Services.AddSingleton<IKnockOutDerivativeResolver, KnockOutDerivativeResolver>();
builder.Services.AddSingleton<ITradeLifecycleService, TradeLifecycleService>();
builder.Services.AddSingleton<IEvaluationHistoryService, EvaluationHistoryService>();
@@ -27,16 +27,17 @@ public class AiReasoningGateService : IAiReasoningGateService
/// on a single external configuration surface.
/// </summary>
private const string BaseInstructions =
"Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " +
"Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " +
"insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " +
"(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " +
"kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " +
"Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " +
"aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " +
"Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " +
"\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " +
"\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " +
"Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System für Long- und Short-Strategien. " +
"Bewerte richtungsbezogen (Long/Buy oder Short/Sell), ob das folgende Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " +
"insbesondere: (1) Widersprechen sich Signal-Richtung, technisches Muster, Sentiment und Fundamentaldaten? " +
"(Bei Long: stützen Momentum, News und Bewertung steigende Kurse? Bei Short: stützen bärische Muster, negatives Sentiment " +
"oder schwache/überbewertete Fundamentaldaten fallende Kurse ohne extreme Squeeze-Gefahr?) " +
"(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen schwer kalkulierbaren Kurssprung (Gap) gegen die Position hin? " +
"(3) Was sagt die Backtest-Historie (falls vorhanden) über die Zuverlässigkeit dieser Strategie für dieses Asset aus? " +
"(4) Passt das Risk/Reward-Verhältnis zum aktuellen Markt-Regime? " +
"Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem Schema, ohne Text davor oder danach: " +
"{\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), \"thesisSummary\": string, \"invalidationReason\": string, " +
"\"keyCatalysts\": string[], \"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " +
"ist günstiger als ein falscher.";
private readonly HttpClient _httpClient;
@@ -0,0 +1,227 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticEngine.Settings;
namespace FinlyticEngine.Services.Scoring;
/// <summary>
/// V2 Implementation of <see cref="ICompositeOpportunityScorer"/> featuring direction-aware fundamental
/// evaluation (Long vs Short), symmetrical sentiment scaling, and short-squeeze awareness.
/// </summary>
public class CompositeOpportunityScorerV2 : ICompositeOpportunityScorer
{
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<CompositeOpportunityScorerV2> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CompositeOpportunityScorerV2"/> class.
/// </summary>
public CompositeOpportunityScorerV2(
ISettingsService settingsService,
IFinlyticLogger<CompositeOpportunityScorerV2> logger)
{
_settingsService = settingsService;
_logger = logger;
}
/// <inheritdoc />
public async Task<ScoringResult> CalculateCompositeScoreAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
{
var wTech = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightTechnical, cancellationToken);
var wSent = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightSentiment, cancellationToken);
var wFund = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightFundamental, cancellationToken);
var lockoutDays = await _settingsService.GetSettingAsync(EngineSettingKeys.EarningsLockoutDays, cancellationToken);
var dividendGateDays = await _settingsService.GetSettingAsync(EngineSettingKeys.DividendGateDays, cancellationToken);
// 1. Technical Score (0..100)
decimal sTech = Math.Clamp(setup.QualityScore, 0m, 100m);
// 2. Sentiment Score (0..100) - Direction aware
decimal sSent = 50m;
if (sentiment?.CurrentSummary != null)
{
decimal compound = (decimal)sentiment.CurrentSummary.CompoundScore; // -1.0 .. +1.0
if (setup.Direction == SignalDirection.Buy)
{
// Compound: -1.0 -> 0, 0.0 -> 50, +1.0 -> 100
sSent = Math.Clamp(((compound + 1.0m) / 2.0m) * 100m, 0m, 100m);
}
else if (setup.Direction == SignalDirection.Sell)
{
// Compound: +1.0 -> 0, 0.0 -> 50, -1.0 -> 100
sSent = Math.Clamp(((1.0m - compound) / 2.0m) * 100m, 0m, 100m);
}
}
// 3. Fundamental Score (0..100) - V2 Direction Aware (Long vs Short)
decimal sFund = 50m;
if (fundamentals?.Fundamentals != null)
{
sFund = CalculateDirectionalFundamentalScore(fundamentals.Fundamentals, setup.Direction);
}
// 4. Earnings Lockout Check
int? daysToEarnings = fundamentals?.DaysToNextEarnings;
bool passedLockout = true;
decimal mEarnings = 1.0m;
if (daysToEarnings.HasValue && daysToEarnings.Value <= lockoutDays && daysToEarnings.Value >= 0)
{
passedLockout = false;
mEarnings = 0.15m; // Strong suppression penalty
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorerV2] ISIN {Isin} hit earnings lockout ({Days} days to earnings). Suppressing score.",
setup.Isin, daysToEarnings.Value);
}
// 4b. Dividend Gate Check
int? daysToExDividend = fundamentals?.DaysToNextExDividend;
bool passedDividendGate = true;
decimal mDividend = 1.0m;
if (daysToExDividend.HasValue && daysToExDividend.Value <= dividendGateDays && daysToExDividend.Value >= 0)
{
passedDividendGate = false;
mDividend = 0.5m; // Moderate suppression penalty
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorerV2] ISIN {Isin} hit dividend gate ({Days} days to ex-dividend). Suppressing score.",
setup.Isin, daysToExDividend.Value);
}
// 5. Backtesting Matrix Feedback-Loop (Score-Bonus or Veto)
decimal matrixBonus = 0m;
bool passedVeto = true;
decimal mVeto = 1.0m;
if (reliability != null)
{
if (reliability.RecommendedAction == "BOOST_SCORE" || (reliability.ProfitFactor >= 1.60m && reliability.SampleTradeCount >= 5))
{
matrixBonus = 15.0m;
await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorerV2] Simulation matrix bonus (+15 pts) applied for {Isin} ({Strategy}): PF={PF:F2}, WR={WR:F1}%",
setup.Isin, setup.StrategyKey, reliability.ProfitFactor, reliability.WinRatePercent);
}
else if (reliability.RecommendedAction == "VETO_DISABLE" || (!reliability.IsStrategyApprovedForAsset && reliability.SampleTradeCount >= 5))
{
passedVeto = false;
mVeto = 0.20m; // Heavy suppression penalty
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorerV2] Simulation matrix VETO applied for {Isin} ({Strategy}): PF={PF:F2} < 1.00. Suppressing score.",
setup.Isin, setup.StrategyKey, reliability.ProfitFactor);
}
}
// 6. Calculate Weighted Composite Opportunity Score (COS)
decimal rawScore = (wTech * sTech) + (wSent * sSent) + (wFund * sFund) + matrixBonus;
decimal finalCos = Math.Clamp(rawScore * mEarnings * mDividend * mVeto, 0m, 100m);
await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorerV2] ISIN {Isin} ({Direction}) evaluated: COS={Cos:F1} (Tech={Tech:F1}, Sent={Sent:F1}, Fund={Fund:F1}, Bonus={Bonus}, Veto={Veto}, Lockout={Lockout}, DividendGate={DividendGate})",
setup.Isin, setup.Direction, finalCos, sTech, sSent, sFund, matrixBonus, passedVeto, passedLockout, passedDividendGate);
return new ScoringResult(
CompositeScore: Math.Round(finalCos, 2),
TechnicalScore: Math.Round(sTech, 2),
SentimentScore: Math.Round(sSent, 2),
FundamentalScore: Math.Round(sFund, 2),
PassedEarningsLockout: passedLockout,
DaysToNextEarnings: daysToEarnings,
ReliabilityBonus: matrixBonus,
PassedSimulationVeto: passedVeto,
PassedDividendGate: passedDividendGate,
DaysToNextExDividend: daysToExDividend
);
}
/// <summary>
/// Computes directional fundamental score tailored specifically for Buy vs Sell opportunities.
/// </summary>
private static decimal CalculateDirectionalFundamentalScore(FundamentalDataDto fund, SignalDirection direction)
{
decimal baseScore = 50m;
if (direction == SignalDirection.Buy)
{
// Forward P/E: Low valuation supports Long (+10), extreme overvaluation penalizes (-10)
if (fund.ForwardPe.HasValue)
{
if (fund.ForwardPe.Value > 0 && fund.ForwardPe.Value < 20m) baseScore += 10m;
else if (fund.ForwardPe.Value > 45m || fund.ForwardPe.Value <= 0) baseScore -= 10m;
}
// Return on Equity: Profitable return on equity supports Long (+10), capital destruction penalizes (-15)
if (fund.ReturnOnEquity.HasValue)
{
if (fund.ReturnOnEquity.Value > 0.15m) baseScore += 10m;
else if (fund.ReturnOnEquity.Value < 0.0m) baseScore -= 15m;
}
// Analyst Consensus
if (!string.IsNullOrWhiteSpace(fund.ConsensusRating))
{
var r = fund.ConsensusRating.ToLowerInvariant();
if (r.Contains("buy") || r.Contains("strong_buy") || r.Contains("outperform")) baseScore += 10m;
else if (r.Contains("sell") || r.Contains("underperform")) baseScore -= 15m;
}
// Debt to Equity penalty for highly leveraged balance sheets on Longs
if (fund.DebtToEquity.HasValue && fund.DebtToEquity.Value > 2.5m)
{
baseScore -= 10m;
}
}
else if (direction == SignalDirection.Sell)
{
// Symmetrical Short evaluation:
// Forward P/E: Extreme valuation or negative earnings supports Short (+12), deep value penalizes (-12)
if (fund.ForwardPe.HasValue)
{
if (fund.ForwardPe.Value > 45m || fund.ForwardPe.Value <= 0) baseScore += 12m;
else if (fund.ForwardPe.Value > 0 && fund.ForwardPe.Value < 15m) baseScore -= 12m;
}
// Return on Equity: Capital destruction / losses supports Short (+15), high cash cow returns penalizes (-12)
if (fund.ReturnOnEquity.HasValue)
{
if (fund.ReturnOnEquity.Value < 0.0m) baseScore += 15m;
else if (fund.ReturnOnEquity.Value > 0.25m) baseScore -= 12m;
}
// Analyst Consensus: Downgrades and Sell ratings confirm Short (+15), Strong Buy opposes Short (-15)
if (!string.IsNullOrWhiteSpace(fund.ConsensusRating))
{
var r = fund.ConsensusRating.ToLowerInvariant();
if (r.Contains("sell") || r.Contains("underperform") || r.Contains("downgrade")) baseScore += 15m;
else if (r.Contains("strong_buy") || r.Contains("outperform")) baseScore -= 15m;
}
// High Debt to Equity adds vulnerability in downtrend (+10)
if (fund.DebtToEquity.HasValue && fund.DebtToEquity.Value > 2.5m)
{
baseScore += 10m;
}
// Short Interest Float check: moderate short interest (5-15%) confirms short thesis (+5),
// but extreme short interest (>25%) warns of dangerous short squeeze risk (-10)
if (fund.ShortPercentOfFloat.HasValue)
{
if (fund.ShortPercentOfFloat.Value is >= 0.05m and <= 0.15m) baseScore += 5m;
else if (fund.ShortPercentOfFloat.Value > 0.25m) baseScore -= 10m;
}
}
return Math.Clamp(baseScore, 0m, 100m);
}
}
@@ -221,6 +221,7 @@ public class ActiveTradeMonitoringBackgroundService : BackgroundService
return new ActiveTradeDto(
TradeId: e.Id,
ProposalId: e.ProposalId,
UserId: e.UserId,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin,
@@ -889,6 +889,7 @@ public class TradeLifecycleService : ITradeLifecycleService
return new ActiveTradeDto(
TradeId: e.Id,
ProposalId: e.ProposalId,
UserId: e.UserId,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin,
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
<ProjectReference Include="..\FinlyticNotify\FinlyticNotify.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticNotify.Services;
using Xunit;
namespace FinlyticNotify.Tests.Services;
public class NotificationFormatterTests
{
private readonly NotificationFormatter _formatter = new();
[Fact]
public void FormatProposalNotification_LongBuy_FormatsCorrectly()
{
// Arrange
var proposal = new TradeProposalDto(
ProposalId: Guid.NewGuid(),
UnderlyingIsin: "US67066G1040",
Symbol: "NVDA",
StrategyKey: "TrendPullbackFvg",
Direction: SignalDirection.Buy,
QualityScore: 88m,
CompositeScore: 84.5m,
CurrentPrice: 125.50m,
EntryPrice: 125.00m,
InvalidationPrice: 120.00m,
ExitPlan: new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: 120.00m,
TakeProfitStages:
[
new TakeProfitStage(1, 135.00m, 1.0m, 2.0m, "TP1: 100% Exit at 135.00")
]
),
SelectedDerivative: new DerivativeSelectionDto(
DerivativeIsin: "DE000TEST123",
DerivativeWkn: null,
Issuer: "HSBC",
OptionType: "LONG",
Strike: 110.0m,
Barrier: 110.0m,
Leverage: 8.2m,
SafetyBufferPercent: 0.08m,
SpreadPercentage: 0.005m,
Size: 100m
),
AiValidation: new AiValidationResultDto(
IsApproved: true,
Confidence: 0.85m,
Source: ValidationSource.Ai,
ThesisSummary: "Bruch des lokalen Abwärtstrends mit starkem Volumen und positiver Halbleiter-Sektor-Dynamik.",
InvalidationReason: "",
KeyCatalysts: ["Earnings Momentum"],
IdentifiedRisks: ["Allgemeine Marktvolatilität"]
),
CreatedAtUtc: DateTime.UtcNow,
ExpiresAtUtc: DateTime.UtcNow.AddHours(4)
);
// Act
var notification = _formatter.FormatProposalNotification(proposal, "finlytic_broadcast");
// Assert
Assert.Equal("finlytic_broadcast", notification.Topic);
Assert.Contains("🟢 Neuer Trade-Vorschlag: NVDA (Long)", notification.Title);
Assert.Equal(4, notification.Priority); // Score >= 80 -> Priority 4
Assert.Contains("chart_with_upwards_trend", notification.Tags!);
Assert.Contains("moneybag", notification.Tags!);
Assert.Contains("**Strategie:** TrendPullbackFvg", notification.Message);
Assert.Contains("125,00", notification.Message.Replace('.', ','));
Assert.Contains("HSBC", notification.Message);
Assert.Contains("Bruch des lokalen Abwärtstrends", notification.Message);
}
[Fact]
public void FormatProposalNotification_ShortSell_FormatsCorrectly()
{
// Arrange
var proposal = new TradeProposalDto(
ProposalId: Guid.NewGuid(),
UnderlyingIsin: "US88160R1014",
Symbol: "TSLA",
StrategyKey: "SmcLiquiditySweep",
Direction: SignalDirection.Sell,
QualityScore: 74m,
CompositeScore: 72.0m,
CurrentPrice: 210.00m,
EntryPrice: 209.50m,
InvalidationPrice: 216.00m,
ExitPlan: new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: 216.00m,
TakeProfitStages: [new TakeProfitStage(1, 196.50m, 1.0m, 2.0m, "TP1")]
),
SelectedDerivative: null,
AiValidation: new AiValidationResultDto(
IsApproved: true,
Confidence: null,
Source: ValidationSource.RuleBased,
ThesisSummary: "Bärischer Liquidity Sweep über dem Vortagshoch mit starker Ablehnung.",
InvalidationReason: "",
KeyCatalysts: [],
IdentifiedRisks: []
),
CreatedAtUtc: DateTime.UtcNow,
ExpiresAtUtc: DateTime.UtcNow.AddHours(4)
);
// Act
var notification = _formatter.FormatProposalNotification(proposal, "finlytic_broadcast");
// Assert
Assert.Equal("finlytic_broadcast", notification.Topic);
Assert.Contains("🔴 Neuer Trade-Vorschlag: TSLA (Short)", notification.Title);
Assert.Equal(3, notification.Priority); // Score < 80 -> Priority 3
Assert.Contains("chart_with_downwards_trend", notification.Tags!);
}
[Fact]
public void FormatTradeStatusNotification_Tp1Hit_FormatsCorrectlyForUser()
{
// Arrange
var trade = new ActiveTradeDto(
TradeId: Guid.NewGuid(),
ProposalId: Guid.NewGuid(),
UserId: Guid.NewGuid(),
UnderlyingIsin: "US0378331005",
Symbol: "AAPL",
DerivativeIsin: null,
DerivativeWkn: null,
ExecutionMode: ExecutionMode.ManualTradeRepublic,
InstrumentType: InstrumentCategoryType.Stock,
Direction: SignalDirection.Buy,
Status: TradeStatus.Tp1Hit,
AverageBuyIn: 150.00m,
TotalQuantity: 10m,
InitialStopLoss: 145.00m,
CurrentStopLoss: 151.00m,
CurrentPrice: 160.00m,
UnrealizedPnlEur: 100.00m,
UnrealizedPnlPercent: 6.67m,
RealizedPnlEur: 0m,
ExitPlan: new ExitPlan(ExitStrategyType.StagedScaleOutWithBreakEven, 145.00m, []),
Fills: [],
OpenedAtUtc: DateTime.UtcNow.AddDays(-1),
ClosedAtUtc: null
);
// Act
var notification = _formatter.FormatTradeStatusNotification(trade, "finlytic_lars");
// Assert
Assert.Equal("finlytic_lars", notification.Topic);
Assert.Contains("🎯 Teilgewinn erreicht (TP1): AAPL", notification.Title);
Assert.Equal(4, notification.Priority);
Assert.Contains("tada", notification.Tags!);
Assert.Contains("Break-Even gesichert", notification.Message);
}
[Fact]
public void FormatTradeStatusNotification_StoppedOut_FormatsCorrectlyForUser()
{
// Arrange
var trade = new ActiveTradeDto(
TradeId: Guid.NewGuid(),
ProposalId: Guid.NewGuid(),
UserId: Guid.NewGuid(),
UnderlyingIsin: "US0378331005",
Symbol: "AAPL",
DerivativeIsin: null,
DerivativeWkn: null,
ExecutionMode: ExecutionMode.ManualTradeRepublic,
InstrumentType: InstrumentCategoryType.Stock,
Direction: SignalDirection.Buy,
Status: TradeStatus.StoppedOut,
AverageBuyIn: 150.00m,
TotalQuantity: 10m,
InitialStopLoss: 145.00m,
CurrentStopLoss: 145.00m,
CurrentPrice: 144.50m,
UnrealizedPnlEur: 0m,
UnrealizedPnlPercent: 0m,
RealizedPnlEur: -55.00m,
ExitPlan: new ExitPlan(ExitStrategyType.FixedSingleTarget, 145.00m, []),
Fills: [],
OpenedAtUtc: DateTime.UtcNow.AddDays(-1),
ClosedAtUtc: DateTime.UtcNow
);
// Act
var notification = _formatter.FormatTradeStatusNotification(trade, "finlytic_john");
// Assert
Assert.Equal("finlytic_john", notification.Topic);
Assert.Contains("🛑 Stop-Loss ausgelöst: AAPL", notification.Title);
Assert.Equal(4, notification.Priority);
Assert.Contains("warning", notification.Tags!);
Assert.Contains("risikokontrolliert geschlossen", notification.Message);
}
[Fact]
public void FormatBotTradeNotification_Active_FormatsCorrectly()
{
// Arrange
var botTrade = new BotTradeOrderDto(
OrderId: Guid.NewGuid(),
ProposalId: Guid.NewGuid(),
Isin: "US5949181045",
Symbol: "MSFT",
Venue: BotExecutionVenue.AlpacaPaperTrading,
AlpacaOrderId: "alpaca_123",
ClientOrderId: "client_123",
Direction: SignalDirection.Buy,
RequestedQuantity: 5m,
FilledQuantity: 5m,
EntryPrice: 420.00m,
AverageBuyIn: 420.50m,
InitialStopLoss: 410.00m,
CurrentStopLoss: 410.00m,
TakeProfit1: 440.00m,
TakeProfit2: 460.00m,
CurrentPrice: 425.00m,
UnrealizedPnlEur: 22.50m,
RealizedPnlEur: 0m,
Status: BotPositionStatus.Active,
ExitPlan: new ExitPlan(ExitStrategyType.FixedSingleTarget, 410.00m, []),
CreatedAtUtc: DateTime.UtcNow,
FilledAtUtc: DateTime.UtcNow,
ClosedAtUtc: null
);
// Act
var notification = _formatter.FormatBotTradeNotification(botTrade, "finlytic_bot");
// Assert
Assert.Equal("finlytic_bot", notification.Topic);
Assert.Contains("🤖 Bot Trade [Active]: MSFT (Long)", notification.Title);
Assert.Contains("robot", notification.Tags!);
}
[Fact]
public void FormatNewsNotification_PositiveSentiment_FormatsCorrectly()
{
// Arrange
var article = new NewsArticleDto
{
Id = Guid.NewGuid(),
Title = "NVIDIA Reports Record Q4 Revenue Driven by AI Chip Demand",
Summary = "NVIDIA exceeded analyst expectations across data center and gaming segments.",
PublishedAt = DateTime.UtcNow,
SourceUrl = "https://example.com/news/nvda-q4",
Sentiment = "POSITIVE",
SentimentScore = 0.88,
Confidence = 0.92,
MatchedAssets =
[
new MatchedAssetDto { Name = "NVIDIA Corp.", Isin = "US67066G1040" }
]
};
// Act
var notification = _formatter.FormatNewsNotification(article, "finlytic_news");
// Assert
Assert.Equal("finlytic_news", notification.Topic);
Assert.Contains("🟢 News (POSITIVE): NVIDIA Corp.", notification.Title);
Assert.Equal(4, notification.Priority); // High confidence + high positive score -> Priority 4
Assert.Contains("newspaper", notification.Tags!);
Assert.Contains("chart_with_upwards_trend", notification.Tags!);
Assert.Contains("NVIDIA Reports Record Q4 Revenue", notification.Message);
Assert.Contains("0.88", notification.Message);
Assert.Equal("https://example.com/news/nvda-q4", notification.ClickUrl);
}
[Fact]
public void FormatNewsNotification_NegativeSentiment_FormatsCorrectly()
{
// Arrange
var article = new NewsArticleDto
{
Id = Guid.NewGuid(),
Title = "Tesla Faces Supply Chain Delays and Reduced Delivery Targets",
Summary = "Tesla lowers annual guidance following factory shutdowns.",
PublishedAt = DateTime.UtcNow,
SourceUrl = "https://example.com/news/tsla-delays",
Sentiment = "NEGATIVE",
SentimentScore = -0.75,
Confidence = 0.85,
MatchedAssets =
[
new MatchedAssetDto { Name = "Tesla Inc.", Isin = "US88160R1014" }
]
};
// Act
var notification = _formatter.FormatNewsNotification(article, "finlytic_news");
// Assert
Assert.Equal("finlytic_news", notification.Topic);
Assert.Contains("🔴 News (NEGATIVE): Tesla Inc.", notification.Title);
Assert.Equal(4, notification.Priority);
Assert.Contains("newspaper", notification.Tags!);
Assert.Contains("chart_with_downwards_trend", notification.Tags!);
Assert.Contains("Tesla Faces Supply Chain Delays", notification.Message);
Assert.Contains("-0.75", notification.Message);
Assert.Equal("https://example.com/news/tsla-delays", notification.ClickUrl);
}
}
@@ -0,0 +1,34 @@
using FinlyticCore.Database;
using FinlyticCore.Entities.Settings;
using Microsoft.EntityFrameworkCore;
namespace FinlyticNotify.Database;
/// <summary>
/// Entity Framework DbContext used by FinlyticNotify exclusively for its own database (finlytic_notify)
/// and dynamic settings.
/// </summary>
public class NotifyDbContext : DbContext, ISettingsDbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="NotifyDbContext"/> class.
/// </summary>
public NotifyDbContext(DbContextOptions<NotifyDbContext> options) : base(options)
{
}
/// <summary>Dynamic settings table for FinlyticNotify.</summary>
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SettingEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FinlyticNotify/FinlyticNotify.csproj", "FinlyticNotify/"]
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
RUN dotnet restore "FinlyticNotify/FinlyticNotify.csproj"
COPY . .
WORKDIR "/src/FinlyticNotify"
RUN dotnet build "FinlyticNotify.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "FinlyticNotify.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FinlyticNotify.dll"]
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,61 @@
// <auto-generated />
using System;
using FinlyticNotify.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticNotify.Migrations
{
[DbContext(typeof(NotifyDbContext))]
[Migration("20260825200608_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticNotify.Migrations
{
/// <inheritdoc />
public partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
}
}
}
@@ -0,0 +1,58 @@
// <auto-generated />
using System;
using FinlyticNotify.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticNotify.Migrations
{
[DbContext(typeof(NotifyDbContext))]
partial class NotifyDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
#pragma warning restore 612, 618
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Net.Http;
using FinlyticCore.Database;
using FinlyticCore.Services;
using FinlyticNotify.Database;
using FinlyticNotify.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
// 1. Register DbContext & Settings Provider (Read-Only user/trade queries + dynamic settings)
builder.Services.AddDbContext<NotifyDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<NotifyDbContext>());
// 2. Register Core Services & Logger
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
// 3. Register In-Memory Cache
builder.Services.AddMemoryCache();
// 4. Register HTTP Client & ntfy Push Client
builder.Services.AddHttpClient<INtfyClient, NtfyClient>()
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
// 5. Register Domain Services
builder.Services.AddSingleton<IUserTradeResolver, UserTradeResolver>();
builder.Services.AddSingleton<INotificationFormatter, NotificationFormatter>();
// 6. Register MQTT Listener & Background Service
builder.Services.AddSingleton<NotifyMqttClient>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<NotifyMqttClient>());
var host = builder.Build();
using (var scope = host.Services.CreateScope())
{
try
{
var context = scope.ServiceProvider.GetRequiredService<NotifyDbContext>();
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred during database migration/seeding.");
}
}
Console.WriteLine("=================================================");
Console.WriteLine(" FinlyticNotify - Push Notification Service (ntfy)");
Console.WriteLine(" Listening exclusively to MQTT Trade Events");
Console.WriteLine("=================================================");
await host.RunAsync();
@@ -0,0 +1,260 @@
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
);
}
}
+337
View File
@@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Managed MQTT client for FinlyticNotify. Listens strictly to existing MQTT broadcast topics,
/// resolves trade ownership, and dispatches rich push notifications via ntfy.
/// </summary>
public class NotifyMqttClient : ManagedMqttClient, IHostedService
{
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly INtfyClient _ntfyClient;
private readonly INotificationFormatter _formatter;
private readonly IUserTradeResolver _userTradeResolver;
private readonly ISettingsService? _settingsService;
private readonly IFinlyticLogger<NotifyMqttClient>? _finlyticLogger;
private readonly ILogger<NotifyMqttClient> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="NotifyMqttClient"/> class.
/// </summary>
public NotifyMqttClient(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
INtfyClient ntfyClient,
INotificationFormatter formatter,
IUserTradeResolver userTradeResolver,
ILogger<NotifyMqttClient> logger,
ISettingsService? settingsService = null,
IFinlyticLogger<NotifyMqttClient>? finlyticLogger = null) : base(logger)
{
_configuration = configuration;
_scopeFactory = scopeFactory;
_ntfyClient = ntfyClient;
_formatter = formatter;
_userTradeResolver = userTradeResolver;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticNotify");
_logger.LogInformation("[NotifyMqttClient] Starting FinlyticNotify MQTT client (Broker: {Host}:{Port}, ClientId: {ClientId})",
config.Host, config.Port, config.ClientId);
await ConnectAsync(config);
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel, "[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
}
await DisconnectAsync();
}
/// <inheritdoc />
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("[NotifyMqttClient] Connected to MQTT broker. Subscribing to trade event topics...");
await SubscribeAsync(MqttTopics.ResponseWildcard);
// 1. Subscribe to Trade Proposals (New Trades / Setups)
await SubscribeAsync(MqttTopics.EngineProposalsCreated);
// 2. Subscribe to Trade Lifecycle Status Changes (Fills, SL-Updates, TPs, Exits)
await SubscribeAsync(MqttTopics.EngineTradesStatusChanged);
// 3. Subscribe to Bot Paper-Trading Streams
await SubscribeAsync(MqttTopics.BotTradesStream);
// 4. Subscribe to News Status Update requests to capture analyzed news events
await SubscribeAsync<UpdateNewsStatusRequest>(
MqttTopics.RequestFilter(MqttTopics.Channels.NewsUpdateStatus), HandleNewsStatusUpdateRequestAsync);
// 5. Subscribe to Service Health Ping for fleet monitoring
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingTopicAsync);
// 6. Subscribe to Dynamic Settings RPC Channels
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(
MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsGetAll), HandleSettingsGetAllRpcAsync);
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(
MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsUpdate), HandleSettingsUpdateRpcAsync);
// 7. Wire structured log broadcasting over MQTT
FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticNotify", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync(MqttTopics.Logs("FinlyticNotify"), logDto);
}
};
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel,
"[NotifyMqttClient] FinlyticNotify MQTT client connected and subscribed to trade and news events.");
}
}
/// <inheritdoc />
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
{
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
string topicPrefix = "finlytic";
if (_settingsService != null)
{
topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix);
}
else
{
topicPrefix = _configuration.GetValue<string>("Ntfy:TopicPrefix") ?? NotifySettingKeys.NtfyTopicPrefix.DefaultValue;
}
topicPrefix = topicPrefix.Trim('/');
try
{
// Case A: New Trade Proposals created by FinlyticEngine
if (topic.Equals(MqttTopics.EngineProposalsCreated, StringComparison.OrdinalIgnoreCase))
{
await HandleProposalCreatedAsync(payloadStr, topicPrefix);
}
// Case B: Trade Status Changed (Lifecycle updates for active trades)
else if (topic.Equals(MqttTopics.EngineTradesStatusChanged, StringComparison.OrdinalIgnoreCase))
{
await HandleTradeStatusChangedAsync(payloadStr, topicPrefix);
}
// Case C: Bot Paper-Trading Execution stream
else if (topic.Equals(MqttTopics.BotTradesStream, StringComparison.OrdinalIgnoreCase))
{
await HandleBotTradeStreamAsync(payloadStr, topicPrefix);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotifyChannel, ex,
"[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic);
}
}
}
private async Task HandleProposalCreatedAsync(string payloadStr, string topicPrefix)
{
bool notifyOnProposals = true;
decimal minScore = 70.0m;
string broadcastChannel = "broadcast";
if (_settingsService != null)
{
notifyOnProposals = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnProposals);
minScore = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyMinProposalScore);
broadcastChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBroadcastChannel);
}
else
{
notifyOnProposals = _configuration.GetValue<bool>("Ntfy:NotifyOnProposals", true);
minScore = _configuration.GetValue<decimal>("Ntfy:MinProposalScore", 70.0m);
broadcastChannel = _configuration.GetValue<string>("Ntfy:BroadcastChannel") ?? "broadcast";
}
if (!notifyOnProposals) return;
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, DefaultJsonOptions);
if (proposal == null) return;
if (proposal.CompositeScore < minScore)
{
_logger.LogDebug("[NotifyMqttClient] Skipping proposal {ProposalId}: CompositeScore {Score} < MinScore {MinScore}",
proposal.ProposalId, proposal.CompositeScore, minScore);
return;
}
string targetTopic = $"{topicPrefix}_{broadcastChannel}";
var notification = _formatter.FormatProposalNotification(proposal, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleTradeStatusChangedAsync(string payloadStr, string topicPrefix)
{
bool notifyOnTradeUpdates = true;
if (_settingsService != null)
{
notifyOnTradeUpdates = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnTradeUpdates);
}
else
{
notifyOnTradeUpdates = _configuration.GetValue<bool>("Ntfy:NotifyOnTradeUpdates", true);
}
if (!notifyOnTradeUpdates) return;
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr, DefaultJsonOptions);
if (trade == null) return;
// Resolve which user owns this trade
string username = await _userTradeResolver.ResolveUsernameByUserIdAsync(trade.UserId);
string targetTopic = $"{topicPrefix}_{username}";
var notification = _formatter.FormatTradeStatusNotification(trade, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleBotTradeStreamAsync(string payloadStr, string topicPrefix)
{
bool notifyOnBotTrades = true;
if (_settingsService != null)
{
notifyOnBotTrades = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnBotTrades);
}
else
{
notifyOnBotTrades = _configuration.GetValue<bool>("Ntfy:NotifyOnBotTrades", true);
}
if (!notifyOnBotTrades) return;
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(payloadStr, DefaultJsonOptions);
if (botTrade == null) return;
string targetTopic = $"{topicPrefix}_bot";
var notification = _formatter.FormatBotTradeNotification(botTrade, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleNewsStatusUpdateRequestAsync(UpdateNewsStatusRequest? req, string topic, string correlationId)
{
if (req == null || req.Id == Guid.Empty) return;
// Only trigger push notifications when an article's status is transitioning to "Analyzed"
if (!string.Equals(req.Status, "Analyzed", StringComparison.OrdinalIgnoreCase)) return;
bool notifyOnNews = true;
string newsChannel = "news";
string topicPrefix = "finlytic";
if (_settingsService != null)
{
notifyOnNews = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnNews);
newsChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyNewsChannel);
topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix);
}
else
{
notifyOnNews = _configuration.GetValue<bool>("Ntfy:NotifyOnNews", true);
newsChannel = _configuration.GetValue<string>("Ntfy:NewsChannel") ?? "news";
topicPrefix = _configuration.GetValue<string>("Ntfy:TopicPrefix") ?? "finlytic";
}
if (!notifyOnNews) return;
try
{
// Query FinlyticNews via existing news_GetById RPC channel to get the full enriched NewsArticleDto
var article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
MqttTopics.Channels.NewsGetById,
new ArticleRequest(req.Id.ToString(), req.Id.ToString()),
TimeSpan.FromSeconds(5));
if (article != null)
{
string targetTopic = $"{topicPrefix.Trim('/')}_{newsChannel}";
var notification = _formatter.FormatNewsNotification(article, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[NotifyMqttClient] Failed to fetch analyzed news article {ArticleId} via news_GetById RPC.", req.Id);
}
}
private async Task HandleHealthPingTopicAsync(object? _, string topic, string correlationId)
{
if (topic.Contains("FinlyticNotify", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase))
{
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticNotify", "Online", DateTime.UtcNow, "Connected"));
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.HealthPingChannel,
"[FinlyticNotify] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId);
}
}
}
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
}
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
}
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
}
}
+188
View File
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Services;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Model representing a notification payload to be dispatched via ntfy.
/// </summary>
public record NtfyNotification(
string Topic,
string Title,
string Message,
int Priority = 3,
List<string>? Tags = null,
string? ClickUrl = null
);
/// <summary>
/// Client interface for sending push notifications to an ntfy instance.
/// </summary>
public interface INtfyClient
{
/// <summary>
/// Sends a notification to the specified ntfy topic.
/// </summary>
/// <param name="notification">The notification payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successfully delivered, false otherwise.</returns>
Task<bool> SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default);
}
/// <summary>
/// High-performance HTTP client for dispatching push notifications to self-hosted ntfy server via JSON payload.
/// </summary>
public class NtfyClient : INtfyClient
{
private readonly HttpClient _httpClient;
private readonly ISettingsService? _settingsService;
private readonly IConfiguration _configuration;
private readonly IFinlyticLogger<NtfyClient>? _finlyticLogger;
private readonly ILogger<NtfyClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Initializes a new instance of the <see cref="NtfyClient"/> class.
/// </summary>
public NtfyClient(
HttpClient httpClient,
IConfiguration configuration,
ILogger<NtfyClient> logger,
ISettingsService? settingsService = null,
IFinlyticLogger<NtfyClient>? finlyticLogger = null)
{
_httpClient = httpClient;
_configuration = configuration;
_logger = logger;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task<bool> SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(notification.Topic))
{
_logger.LogWarning("[NtfyClient] Aborting send: Topic is empty.");
return false;
}
string baseUrl = "http://localhost:8080";
if (_settingsService != null)
{
try
{
baseUrl = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBaseUrl, cancellationToken);
}
catch
{
baseUrl = _configuration.GetValue<string>("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue;
}
}
else
{
baseUrl = _configuration.GetValue<string>("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue;
}
baseUrl = baseUrl.TrimEnd('/');
// Build native ntfy JSON payload (preserves full UTF-8 Unicode, Emojis, and Markdown without HTTP header ASCII constraints)
var payload = new Dictionary<string, object?>
{
["topic"] = notification.Topic.TrimStart('/'),
["title"] = notification.Title,
["message"] = notification.Message,
["priority"] = Math.Clamp(notification.Priority, 1, 5),
["tags"] = notification.Tags,
["click"] = notification.ClickUrl,
["markdown"] = true
};
string json = JsonSerializer.Serialize(payload, JsonOptions);
string? token = null;
string? authUser = null;
string? authPass = null;
if (_settingsService != null)
{
try
{
token = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyAuthToken, cancellationToken);
authUser = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyUsername, cancellationToken);
authPass = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyPassword, cancellationToken);
}
catch { }
}
token = string.IsNullOrWhiteSpace(token) ? _configuration.GetValue<string>("Ntfy:AuthToken") : token;
authUser = string.IsNullOrWhiteSpace(authUser) ? _configuration.GetValue<string>("Ntfy:Username") : authUser;
authPass = string.IsNullOrWhiteSpace(authPass) ? _configuration.GetValue<string>("Ntfy:Password") : authPass;
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Trim());
}
else if (!string.IsNullOrWhiteSpace(authUser) && !string.IsNullOrWhiteSpace(authPass))
{
var authBytes = Encoding.UTF8.GetBytes($"{authUser}:{authPass}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
}
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("[NtfyClient] Push notification successfully delivered to {TargetUrl} (Topic: {Topic})", baseUrl, notification.Topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.NotificationDeliveryChannel,
"[NtfyDelivery] Push notification successfully delivered to topic '{Topic}' (HTTP {StatusCode})",
notification.Topic, (int)response.StatusCode);
}
return true;
}
string errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("[NtfyClient] Failed to send notification to {TargetUrl} for topic {Topic}. HTTP {Status}: {Body}",
baseUrl, notification.Topic, (int)response.StatusCode, errorBody);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogWarningAsync(NotifySettingKeys.NotificationDeliveryChannel,
"[NtfyDelivery] Failed to send push notification to topic '{Topic}' (HTTP {StatusCode})",
notification.Topic, (int)response.StatusCode);
}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "[NtfyClient] Unexpected error sending push notification to {TargetUrl} for topic {Topic}", baseUrl, notification.Topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotificationDeliveryChannel, ex,
"[NtfyDelivery] Error sending push notification to topic '{Topic}'", notification.Topic);
}
return false;
}
}
}
@@ -0,0 +1,113 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Service interface for resolving a UserId GUID to a clean username for ntfy channel addressing.
/// </summary>
public interface IUserTradeResolver
{
/// <summary>
/// Resolves the clean username for a given UserId GUID via in-memory cache and FinlyticBackend MQTT RPC.
/// </summary>
/// <param name="userId">The unique ID of the user owning the trade.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved username (e.g. "lars", "kleidukos", "admin") for ntfy channel addressing.</returns>
Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of <see cref="IUserTradeResolver"/> performing cached MQTT RPC calls to FinlyticBackend
/// without any direct cross-database dependencies.
/// </summary>
public class UserTradeResolver : IUserTradeResolver
{
private readonly IServiceProvider _serviceProvider;
private readonly IMemoryCache _cache;
private readonly ISettingsService _settingsService;
private readonly IConfiguration _configuration;
private readonly ILogger<UserTradeResolver> _logger;
private static readonly Regex InvalidChannelCharRegex = new("[^a-zA-Z0-9_-]", RegexOptions.Compiled);
/// <summary>
/// Initializes a new instance of the <see cref="UserTradeResolver"/> class.
/// </summary>
public UserTradeResolver(
IServiceProvider serviceProvider,
IMemoryCache cache,
ISettingsService settingsService,
IConfiguration configuration,
ILogger<UserTradeResolver> logger)
{
_serviceProvider = serviceProvider;
_cache = cache;
_settingsService = settingsService;
_configuration = configuration;
_logger = logger;
}
/// <inheritdoc />
public async Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
string defaultUsername = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyDefaultUsername, cancellationToken);
if (string.IsNullOrWhiteSpace(defaultUsername))
{
defaultUsername = _configuration.GetValue<string>("Ntfy:DefaultUsername") ?? "admin";
}
if (userId == Guid.Empty)
{
return defaultUsername;
}
string cacheKey = $"user_name_{userId}";
if (_cache.TryGetValue(cacheKey, out string? cachedUsername) && !string.IsNullOrWhiteSpace(cachedUsername))
{
return cachedUsername;
}
try
{
var mqttClient = _serviceProvider.GetService<NotifyMqttClient>();
if (mqttClient != null && mqttClient.IsConnected)
{
var username = await mqttClient.SendRpcRequestAsync<string, UserIdRequest>(
MqttTopics.Channels.BackendGetUsername,
new UserIdRequest(userId),
TimeSpan.FromSeconds(2));
if (!string.IsNullOrWhiteSpace(username))
{
string cleanChannel = CleanUsername(username);
_cache.Set(cacheKey, cleanChannel, TimeSpan.FromHours(1));
return cleanChannel;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[UserTradeResolver] Failed to resolve username from FinlyticBackend for UserId {UserId}. Falling back to default.", userId);
}
return defaultUsername;
}
private static string CleanUsername(string rawName)
{
var cleaned = rawName.Trim().ToLowerInvariant().Replace(" ", "_");
cleaned = InvalidChannelCharRegex.Replace(cleaned, "");
return string.IsNullOrWhiteSpace(cleaned) ? "admin" : cleaned;
}
}
@@ -0,0 +1,37 @@
using FinlyticCore.Models.Settings;
namespace FinlyticNotify.Settings;
/// <summary>
/// Definition of all typed dynamic configuration keys and standard settings for FinlyticNotify.
/// </summary>
public static class NotifySettingKeys
{
// --- Logging Channels ---
public static readonly SettingKey<bool> NotifyChannel = new("Logging.Channel.Notify", true);
public static readonly SettingKey<bool> NotificationDeliveryChannel = new("Logging.Channel.NotificationDelivery", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
// --- ntfy Server & Topic Configuration ---
public static readonly SettingKey<string> NtfyBaseUrl = new("Ntfy.BaseUrl", "http://localhost:8080");
public static readonly SettingKey<string> NtfyTopicPrefix = new("Ntfy.TopicPrefix", "finlytic");
public static readonly SettingKey<string> NtfyBroadcastChannel = new("Ntfy.BroadcastChannel", "broadcast");
public static readonly SettingKey<string> NtfyNewsChannel = new("Ntfy.NewsChannel", "news");
public static readonly SettingKey<string> NtfyDefaultUsername = new("Ntfy.DefaultUsername", "admin");
// --- Authentication (Optional for secured/private ntfy instances) ---
public static readonly SettingKey<string> NtfyAuthToken = new("Ntfy.AuthToken", "");
public static readonly SettingKey<string> NtfyUsername = new("Ntfy.Username", "");
public static readonly SettingKey<string> NtfyPassword = new("Ntfy.Password", "");
// --- Notification Filters & Toggles ---
public static readonly SettingKey<decimal> NtfyMinProposalScore = new("Ntfy.MinProposalScore", 70.0m);
public static readonly SettingKey<bool> NotifyOnProposals = new("Ntfy.NotifyOnProposals", true);
public static readonly SettingKey<bool> NotifyOnTradeUpdates = new("Ntfy.NotifyOnTradeUpdates", true);
public static readonly SettingKey<bool> NotifyOnBotTrades = new("Ntfy.NotifyOnBotTrades", true);
public static readonly SettingKey<bool> NotifyOnNews = new("Ntfy.NotifyOnNews", true);
// --- Click URL & Frontend Integration ---
public static readonly SettingKey<string> ClickBaseUrl = new("Ntfy.ClickBaseUrl", "http://localhost:3000");
}
+26
View File
@@ -0,0 +1,26 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=finlytic;Username=postgres;Password=postgres"
},
"Mqtt": {
"BrokerHost": "localhost",
"BrokerPort": 1883,
"ClientId": "FinlyticNotify"
},
"Ntfy": {
"BaseUrl": "http://localhost:8080",
"TopicPrefix": "finlytic",
"BroadcastChannel": "broadcast",
"DefaultUsername": "admin",
"MinProposalScore": 70.0,
"NotifyOnProposals": true,
"NotifyOnTradeUpdates": true,
"NotifyOnBotTrades": true
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
@@ -100,12 +100,8 @@ public class VirtualBacktestBroker
decimal quantity = Math.Round(riskAmountEur / unitRisk, 2);
if (quantity <= 0) quantity = 1;
// Apply slippage to entry
decimal slippage = _includeFeesAndSlippage ? setup.EntryPrice * _slippagePercent : 0m;
decimal executedPrice = setup.Direction == SignalDirection.Buy
? setup.EntryPrice + slippage
: setup.EntryPrice - slippage;
// No artificial slippage added to price - market frictions are accounted for via transaction fees (_orderFeeEur)
decimal executedPrice = setup.EntryPrice;
decimal fee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
decimal? barrier = null;
@@ -208,6 +204,19 @@ public class VirtualBacktestBroker
pos.RemainingQuantity -= partialQty;
pos.Tp1Hit = true;
pos.CurrentStopLoss = pos.ExecutedEntryPrice; // Move to Break-Even!
// Conservative Intrabar Worst-Case Check: If the same candle also touched or pierced the new Break-Even level,
// conservatively stop out the remaining quantity at Break-Even immediately to prevent lookahead bias.
bool intrabarBreakEvenHit = pos.Direction == SignalDirection.Buy
? candle.Low <= pos.ExecutedEntryPrice
: candle.High >= pos.ExecutedEntryPrice;
if (intrabarBreakEvenHit)
{
ClosePosition(pos, candle.Timestamp, pos.ExecutedEntryPrice, "BreakEven");
_openPositions.RemoveAt(i);
continue;
}
}
}
@@ -266,11 +275,8 @@ public class VirtualBacktestBroker
private void ClosePosition(VirtualPosition pos, DateTime exitTime, decimal rawExitPrice, string exitReason, bool totalLoss = false)
{
decimal slippage = _includeFeesAndSlippage ? rawExitPrice * _slippagePercent : 0m;
decimal exitPrice = pos.Direction == SignalDirection.Buy
? rawExitPrice - slippage
: rawExitPrice + slippage;
// No artificial slippage added/subtracted to exit price - transaction frictions are accounted for via _orderFeeEur
decimal exitPrice = rawExitPrice;
decimal exitFee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
pos.TotalFees += exitFee;
@@ -11,7 +11,7 @@ public static class SimulationSettingKeys
public static readonly SettingKey<bool> MatrixChannel = new("Logging.Channel.Matrix", true);
// --- Simulation & Fee Defaults ---
public static readonly SettingKey<decimal> DefaultSlippagePercent = new("Simulation.DefaultSlippagePercent", 0.05m); // 0.05%
public static readonly SettingKey<decimal> DefaultSlippagePercent = new("Simulation.DefaultSlippagePercent", 0.0m); // 0.00% (Market frictions are accounted for via transaction fees)
public static readonly SettingKey<decimal> DefaultOrderFeeEur = new("Simulation.DefaultOrderFeeEur", 1.00m); // 1.00 € pro Order
public static readonly SettingKey<decimal> DefaultStartingCapital = new("Simulation.DefaultStartingCapital", 10000m);
public static readonly SettingKey<int> MinSampleTradesForApproval = new("Simulation.MinSampleTradesForApproval", 5);
@@ -62,8 +62,7 @@ public static class TechnicalIndicatorsEngine
public static decimal CalculateEma(IReadOnlyList<CandleDto> candles, int period)
{
if (candles == null || candles.Count == 0 || period <= 0) return 0m;
if (candles.Count < period) return CalculateSma(candles, candles.Count);
if (candles == null || candles.Count < period || period <= 0) return 0m;
decimal k = 2m / (period + 1);
// Seed with SMA
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticTechnicals.Patterns.ChartPatterns;
/// <summary>
/// Detects Double Top (M-reversal) formation where price tests a major resistance peak twice and breaks lower.
/// </summary>
public class DoubleTopDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.DoubleTop;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 25) return null;
var recent = candles.TakeLast(25).ToList();
decimal max1 = decimal.MinValue;
int max1Idx = -1;
decimal max2 = decimal.MinValue;
int max2Idx = -1;
decimal troughBetween = decimal.MaxValue;
// Search for two prominent swing highs
for (int i = 2; i < recent.Count - 2; i++)
{
if (recent[i].High >= recent[i - 1].High && recent[i].High >= recent[i - 2].High &&
recent[i].High >= recent[i + 1].High && recent[i].High >= recent[i + 2].High)
{
if (max1Idx == -1)
{
max1 = recent[i].High;
max1Idx = i;
}
else if (max2Idx == -1 && i > max1Idx + 4)
{
max2 = recent[i].High;
max2Idx = i;
break;
}
}
}
if (max1Idx != -1 && max2Idx != -1)
{
// Calculate trough between the two highs (neckline)
for (int i = max1Idx; i <= max2Idx; i++)
{
if (recent[i].Low < troughBetween) troughBetween = recent[i].Low;
}
decimal priceDifference = Math.Abs(max1 - max2) / max1;
var current = recent.Last();
// Double Top validation: highs within 1.5% of each other, neckline clearly below highs
if (priceDifference <= 0.015m && current.Close <= max2 && troughBetween < max1 * 0.99m)
{
decimal target = troughBetween - (Math.Max(max1, max2) - troughBetween);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.DoubleTop,
Category: PatternCategory.Chart,
Bias: PatternBias.Bearish,
Name: "Double Top (M-Pattern)",
Timeframe: context.Timeframe,
DetectedAt: current.Timestamp,
KeyPriceLevel: troughBetween,
UpperBoundary: Math.Max(max1, max2),
LowerBoundary: target,
InvalidationLevel: Math.Max(max1, max2) * 1.005m,
QualityScore: 82m,
Description: $"Double top with peaks at {max1:F2} & {max2:F2}, neckline support at {troughBetween:F2}."
);
}
}
return null;
}
}
/// <summary>
/// Detects Inverse Head &amp; Shoulders (bullish reversal) formation.
/// </summary>
public class InverseHeadAndShouldersDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.InverseHeadAndShoulders;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
var recent = candles.TakeLast(30).ToList();
// Look for Left Shoulder Low, Head Low (lowest), Right Shoulder Low
decimal minPrice = recent.Min(c => c.Low);
int headIdx = recent.FindIndex(c => c.Low == minPrice);
if (headIdx >= 5 && headIdx <= recent.Count - 5)
{
decimal leftShoulderLow = recent.Take(headIdx).Min(c => c.Low);
decimal rightShoulderLow = recent.Skip(headIdx + 1).Min(c => c.Low);
// Head must be strictly lower than both shoulders
if (minPrice < leftShoulderLow * 0.99m && minPrice < rightShoulderLow * 0.99m &&
Math.Abs(leftShoulderLow - rightShoulderLow) / leftShoulderLow <= 0.03m)
{
decimal neckline = recent.Skip(headIdx - 3).Take(6).Max(c => c.High);
var current = recent.Last();
if (current.Close >= rightShoulderLow)
{
decimal target = neckline + (neckline - minPrice);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.InverseHeadAndShoulders,
Category: PatternCategory.Chart,
Bias: PatternBias.Bullish,
Name: "Inverse Head & Shoulders",
Timeframe: context.Timeframe,
DetectedAt: current.Timestamp,
KeyPriceLevel: neckline,
UpperBoundary: target,
LowerBoundary: minPrice,
InvalidationLevel: minPrice * 0.995m,
QualityScore: 85m,
Description: $"Bullish Inverse Head & Shoulders with Head low at {minPrice:F2}, Shoulders ~{leftShoulderLow:F2}, Neckline at {neckline:F2}."
);
}
}
}
return null;
}
}
/// <summary>
/// Detects Descending Triangle (bearish continuation / breakdown) consolidation.
/// </summary>
public class DescendingTriangleDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.DescendingTriangle;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 20) return null;
var recent = candles.TakeLast(20).ToList();
decimal lowSupport = recent.Take(15).Min(c => c.Low);
// Check if lows are flat (horizontal support) while highs are falling (lower highs)
decimal high1 = recent.Take(7).Max(c => c.High);
decimal high2 = recent.Skip(7).Take(7).Max(c => c.High);
decimal high3 = recent.Skip(14).Max(c => c.High);
if (high3 < high2 && high2 < high1 && Math.Abs(recent.Last().Low - lowSupport) / Math.Max(lowSupport, 0.01m) <= 0.01m)
{
var curr = recent.Last();
decimal target = lowSupport - (high1 - lowSupport);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.DescendingTriangle,
Category: PatternCategory.Chart,
Bias: PatternBias.Bearish,
Name: "Descending Triangle",
Timeframe: context.Timeframe,
DetectedAt: curr.Timestamp,
KeyPriceLevel: lowSupport,
UpperBoundary: high3,
LowerBoundary: target,
InvalidationLevel: high3 * 1.005m,
QualityScore: 80m,
Description: $"Descending triangle with horizontal support at {lowSupport:F2} and descending highs ({high1:F2} -> {high2:F2} -> {high3:F2})."
);
}
return null;
}
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticTechnicals.Patterns.SmartMoney;
/// <summary>
/// Detects institutional Bearish Order Blocks (last bullish candle before a strong downward displacement).
/// </summary>
public class BearishOrderBlockDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.OrderBlock;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.SmartMoney;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 5) return null;
var obCandle = candles[^3];
var impulse1 = candles[^2];
var impulse2 = candles.Last();
// Bearish Order Block: Green candle followed by 2 strong red candles that drop price > 1.5 ATR
if (obCandle.Close > obCandle.Open && impulse1.Close < impulse1.Open && impulse2.Close < impulse2.Open)
{
decimal displacement = obCandle.High - impulse2.Close;
if (displacement >= context.CurrentAtr * 1.5m)
{
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.OrderBlock,
Category: PatternCategory.SmartMoney,
Bias: PatternBias.Bearish,
Name: "Bearish Institutional Order Block",
Timeframe: context.Timeframe,
DetectedAt: impulse2.Timestamp,
KeyPriceLevel: (obCandle.Open + obCandle.Close) / 2m,
UpperBoundary: obCandle.High,
LowerBoundary: obCandle.Low,
InvalidationLevel: obCandle.High * 1.005m,
QualityScore: 86m,
Description: $"Bearish order block zone [{obCandle.Low:F2} - {obCandle.High:F2}] with strong downward displacement."
);
}
}
return null;
}
}
+6 -1
View File
@@ -56,6 +56,10 @@ builder.Services.AddSingleton<IPatternDetector, FairValueGapDetector>();
builder.Services.AddSingleton<IPatternDetector, LiquiditySweepDetector>();
builder.Services.AddSingleton<IPatternDetector, ChochBosDetector>();
builder.Services.AddSingleton<IPatternDetector, OrderBlockDetector>();
builder.Services.AddSingleton<IPatternDetector, DoubleTopDetector>();
builder.Services.AddSingleton<IPatternDetector, InverseHeadAndShouldersDetector>();
builder.Services.AddSingleton<IPatternDetector, DescendingTriangleDetector>();
builder.Services.AddSingleton<IPatternDetector, BearishOrderBlockDetector>();
// 6. Register Strategies
builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>();
@@ -70,7 +74,8 @@ builder.Services.AddSingleton<ITechnicalStrategy, DonchianBreakoutStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>();
// 7. Register Technical Scoring Engine & Universe Manager
builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngine>();
// builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngine>(); // V1 Fallback
builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngineV2>(); // V2 Bidirectional Active
builder.Services.AddSingleton<ITechnicalUniverseManager, TechnicalUniverseManager>();
// 8. Register MQTT Client & RPC Bridge
@@ -246,14 +246,14 @@ public class TechnicalScoringEngine : ITechnicalScoringEngine
decimal score = 50m;
if (dir == SignalDirection.Buy)
{
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > e50) score += 15m;
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 > e50) score += 15m;
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m;
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
if (ind.TryGetValue("VWAP", out var vwap) && ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap) score += 10m;
if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap) score += 10m;
}
else if (dir == SignalDirection.Sell)
{
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 < e50) score += 15m;
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 < e50) score += 15m;
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m;
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
}
@@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticTechnicals.Database;
using FinlyticTechnicals.Entities;
using FinlyticTechnicals.Indicators;
using FinlyticTechnicals.Patterns;
using FinlyticTechnicals.Strategies;
using FinlyticTechnicals.Util;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticTechnicals.Services;
/// <summary>
/// V2 Implementation of <see cref="ITechnicalScoringEngine"/> featuring fully symmetrical,
/// bidirectional (Long &amp; Short) indicator confluence math, direction-aware pattern filtering,
/// and regime-aligned scoring without Long-bias.
/// </summary>
public class TechnicalScoringEngineV2 : ITechnicalScoringEngine
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMultiTimeframeCandleAggregator _aggregator;
private readonly IYahooMarketDataScraper _yahooScraper;
private readonly IEnumerable<IPatternDetector> _patternDetectors;
private readonly IEnumerable<ITechnicalStrategy> _strategies;
private readonly IFinlyticLogger<TechnicalScoringEngineV2> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TechnicalScoringEngineV2"/> class.
/// </summary>
public TechnicalScoringEngineV2(
IServiceScopeFactory scopeFactory,
IMultiTimeframeCandleAggregator aggregator,
IYahooMarketDataScraper yahooScraper,
IEnumerable<IPatternDetector> patternDetectors,
IEnumerable<ITechnicalStrategy> strategies,
IFinlyticLogger<TechnicalScoringEngineV2> logger)
{
_scopeFactory = scopeFactory;
_aggregator = aggregator;
_yahooScraper = yahooScraper;
_patternDetectors = patternDetectors;
_strategies = strategies;
_logger = logger;
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> AnalyzeIsinAsync(
string isin,
string? symbol = null,
UniverseSource? universeSource = null,
DateTime? universeEnteredAtUtc = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles are available in ring buffers
var candles15m = _aggregator.GetCandles(cleanIsin, "15m");
var candles1h = _aggregator.GetCandles(cleanIsin, "1h");
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
if (candles1d.Count < 20 || candles15m.Count < 10)
{
// Backfill deep history from Yahoo
var dailyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "1y", "1d", cancellationToken);
if (dailyRes.Count > 0)
{
var dailyDtos = dailyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1d", dailyDtos);
}
var hourlyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "60d", "1h", cancellationToken);
if (hourlyRes.Count > 0)
{
var hourlyDtos = hourlyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1h", hourlyDtos);
}
var min15Res = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "10d", "15m", cancellationToken);
if (min15Res.Count > 0)
{
var min15Dtos = min15Res.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "15m", min15Dtos);
}
}
var allTimeframes = _aggregator.GetAllTimeframes(cleanIsin);
var primaryCandles = _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1d");
}
if (primaryCandles.Count < 5)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalScoringEngineV2] Insufficient candles for ISIN {Isin}", cleanIsin);
return [];
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
// Determine Market Regime symmetrically
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
// Build TechnicalContext
var indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
};
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "15m",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = allTimeframes,
Indicators = indicators
};
// 3. Run all Pattern Detectors
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Pattern detector {Detector} threw an exception for ISIN {Isin}", detector.GetType().Name, cleanIsin);
}
}
// 4. Run all Strategies
var evaluatedSetups = new List<StrategyResultDto>();
foreach (var strategy in _strategies.OrderBy(s => s.Priority))
{
try
{
if (!strategy.IsApplicable(regime)) continue;
var setup = strategy.Evaluate(context, detectedPatterns);
if (setup != null)
{
// Confluence Scoring Calculation:
// FinalScore = 0.35 * S_ind + 0.35 * S_pattern + 0.30 * S_strat
decimal indicatorScore = CalculateIndicatorConfluenceScoreV2(indicators, setup.Direction, lastCandle.Close);
decimal patternScore = CalculateDirectionalPatternScore(detectedPatterns, setup.Direction);
decimal strategyBaseScore = setup.QualityScore;
decimal finalScore = (0.35m * indicatorScore) + (0.35m * patternScore) + (0.30m * strategyBaseScore);
finalScore = Math.Clamp(finalScore, 0m, 100m);
bool isTopPick = finalScore >= 75.0m;
string rating = finalScore >= 85.0m ? "A+" :
finalScore >= 75.0m ? "A" :
finalScore >= 60.0m ? "B" : "C";
var scoredSetup = setup with
{
QualityScore = finalScore,
IsTopPick = isTopPick,
Rating = rating,
UniverseSource = universeSource,
UniverseEnteredAtUtc = universeEnteredAtUtc,
Regime = regime
};
evaluatedSetups.Add(scoredSetup);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Strategy {Strategy} threw an exception for ISIN {Isin}", strategy.StrategyKey, cleanIsin);
}
}
// 5. Persist Setups and Patterns into PostgreSQL
await PersistResultsAsync(cleanIsin, targetSymbol, detectedPatterns, evaluatedSetups);
return evaluatedSetups;
}
/// <summary>
/// Calculates symmetrical indicator confluence score for both Buy and Sell directions (0..100).
/// </summary>
private static decimal CalculateIndicatorConfluenceScoreV2(Dictionary<string, decimal> ind, SignalDirection dir, decimal currentPrice)
{
decimal score = 50m;
if (dir == SignalDirection.Buy)
{
// Trend alignment: Fast EMA above Slow EMA (+15)
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 > e50) score += 15m;
// Momentum in bullish expansion / pull-back zone (+15)
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m;
// Trend strength confirmation (+10)
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
// Price acceptance above VWAP (+10)
if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice > vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap))) score += 10m;
}
else if (dir == SignalDirection.Sell)
{
// Symmetrical Trend alignment: Fast EMA below Slow EMA (+15)
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 < e50) score += 15m;
// Symmetrical Momentum in bearish breakdown / relief-rally zone (+15)
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m;
// Trend strength confirmation (+10)
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
// Symmetrical Price rejection below VWAP (+10) -> enables full 100 points for Sell!
if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice < vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > 0m && e20b < vwap))) score += 10m;
}
return Math.Clamp(score, 0m, 100m);
}
/// <summary>
/// Evaluates detected patterns considering directional bias alignment with the setup.
/// </summary>
private static decimal CalculateDirectionalPatternScore(IReadOnlyList<PatternResultDto> patterns, SignalDirection direction)
{
if (patterns.Count == 0) return 50m;
var expectedBias = direction == SignalDirection.Buy ? PatternBias.Bullish : PatternBias.Bearish;
var opposingBias = direction == SignalDirection.Buy ? PatternBias.Bearish : PatternBias.Bullish;
var matchingPatterns = patterns.Where(p => p.Bias == expectedBias || p.Bias == PatternBias.Neutral).ToList();
var opposingPatterns = patterns.Where(p => p.Bias == opposingBias).ToList();
if (matchingPatterns.Count == 0 && opposingPatterns.Count > 0)
{
// Conflicting patterns penalize the score
return Math.Max(30m, 50m - (opposingPatterns.Count * 10m));
}
if (matchingPatterns.Count > 0)
{
decimal avgQuality = matchingPatterns.Average(p => p.QualityScore);
// Deduct minor penalty if conflicting patterns also exist
decimal penalty = opposingPatterns.Count * 5m;
return Math.Clamp(avgQuality - penalty, 0m, 100m);
}
return 50m;
}
/// <inheritdoc />
public async Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles & setups are calculated
var evaluatedSetups = await AnalyzeIsinAsync(cleanIsin, targetSymbol, cancellationToken: cancellationToken);
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
var primaryCandles = candles1d.Count > 0 ? candles1d : _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1h");
}
if (primaryCandles.Count == 0)
{
return null;
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "1d",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = _aggregator.GetAllTimeframes(cleanIsin),
Indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
}
};
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch { }
}
var indicatorList = new List<IndicatorValuesDto>();
var candlesList = primaryCandles.ToList();
for (int i = 0; i < candlesList.Count; i++)
{
var slice = candlesList.Take(i + 1).ToList();
var c = candlesList[i];
var macd = TechnicalIndicatorsEngine.CalculateMacd(slice);
var st = TechnicalIndicatorsEngine.CalculateSuperTrend(slice);
var atr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
indicatorList.Add(new IndicatorValuesDto(
Timestamp: c.Timestamp,
Ema20: TechnicalIndicatorsEngine.CalculateEma(slice, 20),
Sma50: TechnicalIndicatorsEngine.CalculateSma(slice, 50),
Sma200: TechnicalIndicatorsEngine.CalculateSma(slice, 200),
Rsi14: TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
MacdLine: macd.MacdLine,
MacdSignal: macd.SignalLine,
MacdHistogram: macd.Histogram,
Atr14: atr,
Vwap: TechnicalIndicatorsEngine.CalculateVwap(slice),
SupertrendUpper: st.Direction == SignalDirection.Sell ? st.Value : null,
SupertrendLower: st.Direction == SignalDirection.Buy ? st.Value : null,
SupertrendDirection: st.Direction.ToString().ToUpperInvariant(),
RecommendedStopLoss: c.Close - (atr * 2m)
));
}
var chartPatterns = detectedPatterns.Select(p => new ChartPatternDto(
Type: p.Type.ToString(),
Description: p.Description,
UpperLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High), new(lastCandle.Timestamp, p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High) },
LowerLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low), new(lastCandle.Timestamp, p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low) },
ApexTime: lastCandle.Timestamp,
BreakoutSignal: new BreakoutSignalDto(lastCandle.Timestamp, p.Bias.ToString().ToUpperInvariant(), p.KeyPriceLevel > 0m ? p.KeyPriceLevel : lastCandle.Close, p.KeyPriceLevel > 0m ? p.KeyPriceLevel * 1.05m : lastCandle.Close * 1.05m, 5.0m),
ConfidencePercent: p.QualityScore
)).ToList();
var strategySignals = evaluatedSetups.Select(s => new StrategySignalDto(
Type: s.StrategyKey,
Timestamp: s.CreatedAt,
Direction: s.Direction.ToString().ToUpperInvariant(),
Price: s.CurrentPrice,
Description: s.TechnicalRationale
)).ToList();
var marketRegimeDto = new MarketRegimeDto(
VixValue: 18.5m,
VixRegime: regime.ToString(),
MarketTrend: regime == MarketRegime.BullishTrending ? "Bullish" : regime == MarketRegime.BearishTrending ? "Bearish" : "Neutral",
DxyValue: 104.2m,
DxyState: "Neutral",
SummaryText: $"Market Regime: {regime} with ATR {currentAtr:F2}"
);
return new TechnicalAnalysisDto(
Isin: cleanIsin,
Ticker: targetSymbol,
CompanyName: targetSymbol,
LastUpdated: lastCandle.Timestamp,
Candles: candlesList,
Indicators: indicatorList,
Patterns: chartPatterns,
Signals: strategySignals,
MarketRegime: marketRegimeDto,
Currency: "EUR"
);
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> GetActiveSetupsAsync(
bool topPicksOnly = false,
int limit = 50,
decimal? minScore = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var now = DateTime.UtcNow;
var query = db.FtaTechnicalSetups.AsNoTracking()
.Where(s => s.IsActive && s.ExpiresAtUtc > now);
if (topPicksOnly)
{
query = query.Where(s => s.IsTopPick);
}
if (minScore.HasValue)
{
query = query.Where(s => s.QualityScore >= minScore.Value);
}
var entities = await query
.OrderByDescending(s => s.QualityScore)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(MapEntityToDto).ToList();
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(
string isin,
int limit = 8,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var entities = await db.FtaTechnicalSetups.AsNoTracking()
.Where(s => s.Isin == cleanIsin)
.OrderByDescending(s => s.CreatedAtUtc)
.Take(Math.Max(1, limit))
.ToListAsync(cancellationToken);
return entities.Select(MapEntityToDto).ToList();
}
private async Task PersistResultsAsync(string isin, string symbol, List<PatternResultDto> patterns, List<StrategyResultDto> setups)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
// Save detected patterns
foreach (var pat in patterns)
{
db.FtaDetectedPatterns.Add(new FtaDetectedPatternEntity
{
Id = pat.Id,
Isin = isin,
Timeframe = pat.Timeframe,
PatternType = pat.Type.ToString(),
Category = pat.Category.ToString(),
Bias = pat.Bias.ToString(),
Name = pat.Name,
KeyPriceLevel = pat.KeyPriceLevel,
UpperBoundary = pat.UpperBoundary,
LowerBoundary = pat.LowerBoundary,
InvalidationLevel = pat.InvalidationLevel,
QualityScore = pat.QualityScore,
Description = pat.Description,
ExtraData = pat.ExtraData,
DetectedAtUtc = pat.DetectedAt
});
}
// Save strategy setups
foreach (var setup in setups)
{
db.FtaTechnicalSetups.Add(new FtaTechnicalSetupEntity
{
SetupId = setup.SetupId,
Isin = isin,
Symbol = symbol,
Timeframe = setup.Timeframe,
StrategyKey = setup.StrategyKey,
StrategyName = setup.StrategyName,
Direction = setup.Direction.ToString(),
QualityScore = setup.QualityScore,
CurrentPrice = setup.CurrentPrice,
EntryPrice = setup.EntryPrice,
InvalidationPrice = setup.InvalidationPrice,
CurrentAtr = setup.CurrentAtr,
EstimatedRiskRewardRatio = setup.EstimatedRiskRewardRatio,
ExitPlan = setup.ExitPlan,
TechnicalRationale = setup.TechnicalRationale,
TriggeringPatterns = setup.TriggeringPatterns,
IndicatorSnapshot = setup.IndicatorSnapshot,
IsTopPick = setup.IsTopPick,
Rating = setup.Rating,
IsActive = true,
CreatedAtUtc = setup.CreatedAt,
ExpiresAtUtc = setup.ExpiresAt,
UniverseSource = setup.UniverseSource?.ToString(),
UniverseEnteredAtUtc = setup.UniverseEnteredAtUtc,
Regime = setup.Regime?.ToString()
});
}
await db.SaveChangesAsync();
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Failed to persist technical setups for ISIN {Isin}", isin);
}
}
private static StrategyResultDto MapEntityToDto(FtaTechnicalSetupEntity entity)
{
var direction = Enum.TryParse<SignalDirection>(entity.Direction, true, out var dir) ? dir : SignalDirection.Buy;
UniverseSource? universeSource = !string.IsNullOrWhiteSpace(entity.UniverseSource) &&
Enum.TryParse<UniverseSource>(entity.UniverseSource, true, out var src)
? src
: null;
MarketRegime? regime = !string.IsNullOrWhiteSpace(entity.Regime) &&
Enum.TryParse<MarketRegime>(entity.Regime, true, out var reg)
? reg
: null;
return new StrategyResultDto(
SetupId: entity.SetupId,
Isin: entity.Isin,
Symbol: entity.Symbol,
Timeframe: entity.Timeframe,
StrategyKey: entity.StrategyKey,
StrategyName: entity.StrategyName,
Direction: direction,
QualityScore: entity.QualityScore,
CurrentPrice: entity.CurrentPrice,
EntryPrice: entity.EntryPrice,
InvalidationPrice: entity.InvalidationPrice,
CurrentAtr: entity.CurrentAtr,
EstimatedRiskRewardRatio: entity.EstimatedRiskRewardRatio,
ExitPlan: entity.ExitPlan ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, entity.InvalidationPrice, []),
TechnicalRationale: entity.TechnicalRationale,
TriggeringPatterns: entity.TriggeringPatterns ?? [],
IndicatorSnapshot: entity.IndicatorSnapshot ?? new Dictionary<string, decimal>(),
CreatedAt: entity.CreatedAtUtc,
ExpiresAt: entity.ExpiresAtUtc,
IsTopPick: entity.IsTopPick,
Rating: entity.Rating,
UniverseSource: universeSource,
UniverseEnteredAtUtc: entity.UniverseEnteredAtUtc,
Regime: regime
);
}
}
@@ -21,7 +21,6 @@ public class TrendPullbackFvgStrategy : ITechnicalStrategy
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
// Tunable for backtesting only (see TechnicalContext.ParameterOverrides doc comment) - defaults match
// this strategy's original hardcoded values, so live scanning behavior is unchanged.
@@ -30,13 +29,15 @@ public class TrendPullbackFvgStrategy : ITechnicalStrategy
int emaSlowPeriod = (int)context.GetParameter(StrategyKey, "EmaSlow", 200m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.2m);
if (candles.Count < emaSlowPeriod + 5) return null;
var current = candles.Last();
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(candles, emaFastPeriod);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(candles, emaMidPeriod);
decimal ema200 = TechnicalIndicatorsEngine.CalculateEma(candles, emaSlowPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
if (ema20 <= 0m || ema50 <= 0m || ema200 <= 0m || atr <= 0) return null;
// Long Setup: Bullish Trend (EMA20 > EMA50 > EMA200) + Bullish FVG retracement.
bool isBullishTrend = ema20 > ema50 && ema50 > ema200 && current.Close > ema50;
+221
View File
@@ -0,0 +1,221 @@
# Finlytic Problemanalyse & Schwachstellenbericht (PROBLEMS.md)
Dieses Dokument analysiert detailliert alle identifizierten Fehler, logischen Inkonsistenzen, mathematischen/finanziellen Ungenauigkeitsquellen, fehlenden Funktionen und Skalierungsrisiken im gesamten Finlytic-Codebase.
---
## Inhaltsverzeichnis
1. [Kritische Bugs & Logikfehler](#1-kritische-bugs--logikfehler)
2. [Ursachen für Ungenauigkeiten (Inaccuracies & Drift)](#2-ursachen-für-ungenauigkeiten-inaccuracies--drift)
3. [Fehlende Funktionen & Architekturlücken](#3-fehlende-funktionen--architekturlücken)
4. [Skalierungs-, Performance- & Resilienz-Risiken](#4-skalierungs--performance---resilienz-risiken)
5. [Konkreter Maßnahmen- & Optimierungs-Fahrplan](#5-konkreter-maßnahmen---optimierungs-fahrplan)
---
## 1. Kritische Bugs & Logikfehler
### 1.1 Alpaca Bracket-Order Stop-Loss Update Fehler
- **Ort**: `FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs` (Zeilen 123147)
- **Problem**:
Beim Platzieren einer Bracket-Order (`PostOrderAsync`) gibt Alpaca die Order-ID der **übergeordneten Market-Order** zurück. Sobald diese ausgeführt wird, ist diese Order abgeschlossen (`filled`).
In `UpdateStopLossAsync` wird versucht, `client.PatchOrderAsync(new ChangeOrderRequest(orderGuid) { StopPrice = ... })` direkt mit der übergeordneten Market-Order-ID aufzurufen.
- **Auswirkung**:
Alpaca lehnt das Update mit `422 Unprocessable Entity` oder `404 Not Found` ab, da nicht die Parent-Order, sondern die untergeordnete Stop-Loss-Leg-Order gepatcht werden muss.
- **Lösung**:
Nach der Ausführung muss die Order über `client.GetOrderAsync()` abgefragt werden, um die `legs` (Child-Orders) zu inspizieren und die ID der Stop-Loss-Order in `BotPositionEntity` zu speichern.
---
### 1.2 Bot-Positionsüberwachung fragt nicht existierende 1m-Kerzen ab
- **Ort**: `FinlyticBot/Services/Monitoring/BotTradeLifecycleBackgroundService.cs` (Zeile 8286)
- **Problem**:
Der Lifecycle-Service pollt `ta_GetCandles` mit `Timeframe = "1m"`.
`FinlyticTechnicals` befüllt seine Ringpuffer jedoch primär via Yahoo Finance mit den Timeframes `15m`, `1h` und `1d`. Die `1m`-Kerzen werden ausschließlich live generiert, wenn `TradeRepublicIngestionService` für genau dieses Asset Ticks streamt.
- **Auswirkung**:
Für Assets, die nicht aktiv über Trade Republic gestreamt werden, gibt `ta_GetCandles` eine leere Liste zurück (`candles.Count == 0`). Der Bot führt `continue` aus und aktualisiert weder den aktuellen Kurs (`CurrentPrice`), noch prüft er Stop-Loss- oder Take-Profit-Bedingungen.
- **Lösung**:
Fallback auf den kleinsten verfügbaren Timeframe (`15m`) oder direkte Abfrage des letzten Live-Kurses (`tr_GetLivePrice`).
---
### 1.3 Inkonsistente Datenbankbenennung für FinlyticSentiment
- **Ort**: `compose.yaml` (Zeile 138) vs. Dokumentation & Konventionen
- **Problem**:
In `compose.yaml` heißt die Datenbank `finlytic_sentimental`, während die Namenskonvention aller anderen Services `finlytic_{service}` lautet (also `finlytic_sentiment`).
- **Auswirkung**:
Bei automatisierten Backups, Init-Skripten oder manuellen SQL-Inspektionen führt dieser Tippfehler zu Verwirrung oder fehlgeschlagenen Migrations-Skripten.
- **Lösung**:
Vereinheitlichung auf `finlytic_sentiment`.
---
### 1.4 Unbenutzte Gebührenvariable im Synthetischen Ledger
- **Ort**: `FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs` (Zeile 134, 149)
- **Problem**:
In `GetSummaryAsync` wird `decimal totalFees = positions.Sum(p => p.TotalFeesEur);` berechnet, aber in der Equity-Formel nicht verwendet:
`decimal currentEquity = baseCapital + totalRealized + unrealizedPnl;`
*(Hinweis: `totalRealized` hat die Gebühren bereits bei Schließung abgezogen; die Variable `totalFees` ist toter Code).*
- **Lösung**:
Bereinigung oder explizite Dokumentation der Netto-PnL-Logik.
---
## 2. Ursachen für Ungenauigkeiten (Inaccuracies & Drift)
### 2.1 Fehlende Währungskonvertierung (USD vs. EUR bei Alpaca)
- **Ort**: `FinlyticBot/Services/Execution/BotOrderExecutor.cs` & `FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs`
- **Problem**:
- Finlytics Kontoführung, synthetischer Ledger und Risikoberechnungen (`SyntheticBaseCapitalEur`, Sizing-Formel) rechnen strikt in **EUR (€)**.
- Alpaca US-Equities (z.B. AAPL, NVDA) werden in **USD ($)** abgerechnet und bepreist.
- `BotOrderExecutor` übergibt den EUR-Preis 1:1 an Alpaca bzw. nimmt für das Sizing an, dass $1 = 1 €$.
- **Ungenauigkeit**:
Je nach EUR/USD-Wechselkurs (z.B. 1,08) weicht das tatsächliche Risiko um **815%** von der 1%-Risikoregel ab.
- **Lösung**:
Integration eines FX-Umrechnungskurses (z.B. über EZB-Feed oder Yahoo EURUSD=X) in die Sizing- und Positionsbewertungslogik.
---
### 2.2 Warmup-Verzerrung bei EMA 200 & langfristigen Indikatoren `[BEHOBEN]`
- **Ort**: `FinlyticTechnicals/Indicators/TechnicalIndicatorsEngine.cs`, `CoreStrategies.cs` & `TechnicalScoringEngineV2.cs`
- **Problem**:
Wenn für ein neu hinzugefügtes Asset weniger als 200 historische Kerzen vorlagen, wurde der EMA 200 aus den verfügbaren Kerzen berechnet (Fallback auf SMA über z.B. 50 Kerzen).
- **Lösung / Status**:
**Behoben**: `CalculateEma` gibt bei `candles.Count < period` strikt `0m` zurück. `TrendPullbackFvgStrategy` und `MovingAverageCrossoverStrategy` prüfen strikt $\ge 205$ Kerzen, und `TechnicalScoringEngineV2` vergibt Confluence-Punkte nur bei `EMA > 0m`.
---
### 2.3 Intrabar-Pfad-Ungewissheit im Backtesting `[BEHOBEN]`
- **Ort**: `FinlyticSimulation/Engine/VirtualBacktestBroker.cs`
- **Problem**:
Eine Kerze liefert nur $O, H, L, C$. Wenn innerhalb derselben Kerze sowohl das Take-Profit-Level ($H$) als auch das Stop-Loss-Level ($L$) berührt wurden, konnte der Backtester nicht feststellen, welches Extremum zuerst eintrat.
- **Lösung / Status**:
**Behoben**: Konservatives Worst-Case-Prinzip implementiert. Stop-Loss und Knock-Out-Checks werden strikt vor Take-Profit ausgeführt. Wird TP1 in einer Kerze ausgelöst und der Stop auf Break-Even gezogen, wird sofort geprüft, ob das Bar-Tief auch das Break-Even-Level schneidet, um die Restposition ggf. direkt als Break-Even auszustoppen.
---
### 2.4 Feste Slippage `[BEHOBEN / ENTFERNT]`
- **Ort**: `FinlyticSimulation/Engine/VirtualBacktestBroker.cs` & `SimulationSettingKeys.cs`
- **Problem**:
Bisher wurde neben der festen Ordergebühr zusätzlich eine prozentuale Slippage (0.05%) auf Kursdaten angewendet.
- **Lösung / Status**:
**Behoben**: Künstlicher Slippage-Aufschlag/-Abschlag vollständig aus der Kursausführung entfernt; Transaktionskosten werden transparent und sauber über die Ordergebühren (`_orderFeeEur = 1.00 €`) abgebildet. `DefaultSlippagePercent` wurde auf `0.0m` gesetzt.
---
### 2.5 Trade Republic WebSocket-Inaktivitäts-Timeout
- **Ort**: `FinlyticCore/Services/TradeRepublic/TradeRepublicService.cs` (`_inactivityTimer = 461 Sekunden`)
- **Problem**:
Wenn 7,6 Minuten lang keine Anfrage an Trade Republic gestellt wird, schließt der Timer die WebSocket-Verbindung. Bei der nächsten Anfrage muss die Verbindung neu aufgebaut werden.
- **Ungenauigkeit**:
Der Neuaufbau dauert 13 Sekunden. In dieser Zeit schlagen Live-Kurs-Abfragen fehl oder liefern veraltete Cache-Preise.
- **Lösung**:
Automatischer Ping/Keepalive statt Schließung oder resilienter Reconnect mit Retry.
---
### 2.6 Typkonvertierungen (`double` vs. `decimal`)
- **Ort**: Mehrere Services (FinBERT DTOs nutzen `double`, Engine/Technicals nutzen `decimal`)
- **Problem**:
In `CompositeOpportunityScorerV2` wird `(decimal)sentiment.CurrentSummary.CompoundScore` gecastet. Fließkommazahlen (`double`) können binäre Rundungsfehler aufweisen (z.B. `0.15000000000000002`).
- **Lösung**:
Rundung auf 4 Nachkommastellen vor dem Casten (`Math.Round((decimal)score, 4)`).
---
## 3. Fehlende Funktionen & Architekturlücken
### 3.1 Fehlende Short-Derivate-Alternativen bei Trade Republic
- **Ort**: `FinlyticEngine/Services/Derivatives/KnockOutDerivativeResolver.cs`
- **Lücke**:
Wenn `FinlyticTechnicals` ein starkes Short-Signal (Verkauf) generiert, sucht der Resolver ausschließlich nach `knockOutProduct` mit `OptionType.Short` (Put Knock-Outs). Gibt es für das Asset keine KO-Puts bei Trade Republic, scheitert die Derivate-Zuweisung komplett.
- **Erweiterung**:
Automatischer Fallback auf klassische Put-Optionsscheine (`vanillaWarrant`) oder Faktor-Short-Zertifikate.
---
### 3.2 Keine Portfolio-Korrelations- & Branchenrisiko-Prüfung
- **Ort**: `FinlyticBot/Services/Execution/BotOrderExecutor.cs`
- **Lücke**:
Der Bot prüft lediglich, ob `activeCount < MaxConcurrentPositions` (5) ist. Er prüft nicht, ob alle 5 Positionen aus demselben Sektor stammen (z.B. 5x Halbleiter/Tech).
- **Erweiterung**:
Sektoren-Exposure-Limit: Maximal 2 Positionen pro Sektor oder maximal 40% Gesamtallokation in einer Branche.
---
### 3.3 Fehlende Multi-User-Isolation im Bot
- **Ort**: `FinlyticBot/Database/Entities/BotPositionEntity.cs`
- **Lücke**:
`EngineTradeEntity` in `FinlyticEngine` besitzt bereits ein `UserId`-Feld für Multi-Tenancy. `BotPositionEntity` im `FinlyticBot` besitzt jedoch **kein `UserId`-Feld** alle Bot-Trades laufen in einem globalen Pool.
- **Erweiterung**:
Erweiterung von `BotPositionEntity` um `UserId` und Filterung im `BotController` nach dem authentifizierten Benutzer.
---
### 3.4 Fehlender nativer Trailing-Stop bei Alpaca
- **Ort**: `FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs`
- **Lücke**:
Alpaca unterstützt native Trailing-Stop-Orders (`trailing_stop`). Der Service nutzt bisher nur feste Bracket-Orders und versucht, den Stop-Loss diskret im 15s-Polling-Intervall nachzuziehen.
- **Erweiterung**:
Nutzung der nativen Alpaca `TrailingStopOrder`-API für exaktes Tick-basiertes Nachziehen ohne Latenzrisiko.
---
### 3.5 Fehlende Historienbereinigung (Data Retention Cleanup Cron)
- **Ort**: `FinlyticNews`, `FinlyticSentiment`, `FinlyticEngine`
- **Lücke**:
Obwohl `SettingKeys.ArticleRetentionDays` (90 Tage) existiert, läuft kein automatischer Hintergrund-Cleanup-Job, der abgelaufene Artikel, Snapshots oder Logs physisch aus der PostgreSQL-Datenbank löscht.
- **Erweiterung**:
Einrichten eines täglichen Wartungs-Background-Services (`DataRetentionCleanupWorker`).
---
## 4. Skalierungs-, Performance- & Resilienz-Risiken
### 4.1 Unbegrenztes Speicherwachstum bei In-Memory-Ringpuffern
- **Ort**: `FinlyticTechnicals/Services/MultiTimeframeCandleAggregator.cs`
- **Risiko**:
`_buffers` hält für jedes jemals abgefragte Asset ein `ConcurrentDictionary` mit je 500 Kerzen über 5 Timeframes. Werden über den Scanner 10.000 Assets abgefragt, belegt dies mehrere Gigabyte RAM im Container.
- **Lösung**:
Einführung einer LRU-Cache-Bereinigung (z.B. `MemoryCache` mit Ablaufzeit für Assets außerhalb der Watchlist).
---
### 4.2 Playwright-Browser-Instanzen & Zombie-Prozesse
- **Ort**: `FinlyticNews/Services/PlaywrightScraperService.cs` & `FinlyticFundamentals`
- **Risiko**:
Playwright startet Chromium-Headless-Instanzen. Bei Netzwerk-Timeouts oder abrupten Thread-Abbrüchen können verwaiste `chrome`-Prozesse im Docker-Container verbleiben und Speicher/CPU leersaugen.
- **Lösung**:
Striktes `using`-Ressourcenmanagement mit `BrowserContext.CloseAsync()` und Docker-Container-Speicherlimits (`mem_limit` in `compose.yaml`).
---
### 4.3 Rate-Limiting & IP-Blocking bei Yahoo Finance
- **Ort**: `FinlyticCore/Clients/YahooFinanceClient.cs` & `YahooFinanceScraper.cs`
- **Risiko**:
Yahoo Finance besitzt unangekündigte Rate-Limits. Werden 100 Assets parallel gescannt, antwortet Yahoo mit `HTTP 429 Too Many Requests`.
- **Lösung**:
Zentraler Request-Throttler mit Polly-Retry und Exponential-Backoff im `YahooFinanceClient`.
---
### 4.4 Single Point of Failure (MQTT-Broker & OmniDB)
- **Risiko**:
Alle Microservices sind über einen einzelnen MQTT-Broker verbunden. Fällt dieser aus, bricht die gesamte Inter-Service-Kommunikation ab.
- **Lösung**:
Polly-basierte Reconnect-Pipelines sind in `ManagedMqttClient` vorhanden, sollten jedoch mit Offline-Queuing ergänzt werden.
---
## 5. Konkreter Maßnahmen- & Optimierungs-Fahrplan
| Priorität | Bereich | Maßnahme | Aufwand |
| :---: | :--- | :--- | :---: |
| 🔴 **P1** | `FinlyticBot` | **Alpaca Bracket-Order Leg-ID Fix**: Stop-Loss-Leg nach Orderplatzierung ermitteln und speichern, um `UpdateStopLossAsync` funktionsfähig zu machen. | Gering |
| 🔴 **P1** | `FinlyticBot` | **1m-Kerzen-Polling beheben**: Fallback auf `15m` oder `tr_GetLivePrice` im `BotTradeLifecycleBackgroundService`. | Gering |
| 🟡 **P2** | `FinlyticBot` | **USD/EUR Währungskonvertierung**: Integration eines dynamischen Wechselkurses für US-Positionen. | Mittel |
| 🟡 **P2** | `FinlyticEngine` | **Derivate-Fallback erweitern**: Optionsscheine/Faktor-Zertifikate als Fallback bei fehlenden KO-Puts. | Mittel |
| 🟡 **P2** | `FinlyticTechnicals`| **EMA 200 Warmup-Guard**: Keine Signalfreigabe bei unvollständiger Kerzenhistorie ($<200$). | Gering |
| 🟢 **P3** | `FinlyticBot` | **Multi-User Isolation**: `UserId` zu `BotPositionEntity` hinzufügen. | Mittel |
| 🟢 **P3** | `FinlyticCore` | **Data Retention Background-Worker**: Automatisches Löschen alter News/Logs nach 90 Tagen. | Mittel |
| 🟢 **P3** | `FinlyticTechnicals`| **LRU-Cache für Ringpuffer**: Speicherdeckelung bei großen Asset-Zahlen. | Mittel |
+612
View File
@@ -0,0 +1,612 @@
# Finlytic Systemdokumentation (STATE.md)
Dieses Dokument bietet eine lückenlose, detaillierte und strukturierte Gesamtdokumentation der gesamten Finlytic-Plattform. Es umfasst die Architektur, alle Konfigurationen & Einstellungen, mathematische/finanzielle Formeln, sämtliche Schnittstellen (MQTT RPC, MQTT Pub/Sub, REST API, SignalR Hubs), Datenbankstrukturen sowie die genaue Funktionsweise der 9 Microservices und des Flutter-Frontends.
---
## Inhaltsverzeichnis
1. [Systemarchitektur & Topologie](#1-systemarchitektur--topologie)
2. [Microservices-Übersicht & Datenbanken](#2-microservices-übersicht--datenbanken)
3. [Einstellungen & Konfiguration (Settings)](#3-einstellungen--konfiguration-settings)
4. [Mathematische, Technische & Finanzielle Formeln](#4-mathematische-technische--finanzielle-formeln)
5. [Schnittstellen & Endpunkte](#5-schnittstellen--endpunkte)
- [5.1 MQTT RPC-Kanäle](#51-mqtt-rpc-kanäle)
- [5.2 MQTT Pub/Sub Event-Topics](#52-mqtt-pubsub-event-topics)
- [5.3 REST API Endpunkte (FinlyticBackend)](#53-rest-api-endpunkte-finlyticbackend)
- [5.4 SignalR Hubs & Methoden](#54-signalr-hubs--methoden)
6. [Detaillierte Funktionsweise & Datenfluss](#6-detaillierte-funktionsweise--datenfluss)
7. [Frontend-Architektur (FinlyticApp)](#7-frontend-architektur-finlyticapp)
---
## 1. Systemarchitektur & Topologie
Finlytic ist eine modulare, ereignisgesteuerte Finanzanalyse- und automatisierte Trading-Plattform für Aktien und Derivate (Knock-Out-Zertifikate, Optionsscheine).
```
┌────────────────────────────────────────────────────────────────────────┐
│ FinlyticApp (Flutter Web & Mobile) │
└───────────────────────────────────┬────────────────────────────────────┘
│ HTTP / WebSocket (SignalR)
┌────────────────────────────────────────────────────────────────────────┐
│ FinlyticBackend (API & Gateway) │
└───────────────────────────────────┬────────────────────────────────────┘
│ MQTT RPC & Pub/Sub
┌──────────────┬───────────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌───────────┐ ┌─────────────┐ ┌────────────┐
│Finlytic │ │FinlyticNews │ │Finlytic │ │Finlytic │ │Finlytic │
│Assets │ │ │ │Sentiment │ │Fundamentals │ │Technicals │
└────┬─────┘ └──────┬───────┘ └─────┬─────┘ └──────┬──────┘ └─────┬──────┘
│ │ │ │ │
└──────────────┴───────┬───────┴──────────────┴──────────────┘
│ MQTT (Signale, Scores, Setups)
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌────────────┐
│Finlytic │ ──────► │FinlyticBot │ │Finlytic │
│Engine │ (Trades)│(Auto-Trading)│ │Simulation │
└────┬─────┘ └──────────────┘ └────────────┘
┌──────────┐
│Finlytic │ ──────► ntfy Push-Server (Mobil & Webhooks)
│Notify │
└──────────┘
```
### Kernprinzipien & Regeln (Rules.md):
1. **MQTT-Exklusivität im Backend**: Alle internen Microservices kommunizieren ausschließlich über MQTT. Es gibt keine direkten HTTP-Verbindungen zwischen Backend-Diensten.
2. **Einziges Web-Gateway**: `FinlyticBackend` ist der einzige Dienst mit Kestrel-HTTP/WebSocket-Port (`5000:8080`).
3. **Strikte Datenisolation**: Jeder Service besitzt seine eigene PostgreSQL-Datenbank (keine geteilten Tabellen).
4. **Dynamische Konfiguration**: Dynamic Settings (`ISettingsService`) werden in DB persistiert und per MQTT aktualisiert, ohne Neustart.
5. **Kanalbasiertes Logging**: Jeder Service sendet strukturierte Logs per MQTT (`finlytic/logs/{service}`), die im Admin-Panel live gestreamt werden.
6. **Keine Scheindaten**: Reine Echtdaten oder explizite Empty-States/Exceptions.
---
## 2. Microservices-Übersicht & Datenbanken
| Service | Typ / Basis | PostgreSQL-Datenbank | Hauptaufgabe |
| :--- | :--- | :--- | :--- |
| **FinlyticCore** | Shared Class Library | *(Keine eigene DB)* | Gemeinsame DTOs, Enums, MQTT-Clients, TradeRepublic-Client, Settings-Interface. |
| **FinlyticAssets** | Background Worker | `finlytic_assets` | Stammdaten-Synchronisation (Stocks, ETFs), Lokale Logo-Speicherung, Trade Republic Ticker-Proxy, KO-Derivate-Abfrage. |
| **FinlyticNews** | Playwright Worker | `finlytic_news` | Scraping von 10 Finanzportalen, Duplikaterkennung (SimHash/Jaccard), Regex/NLP-Asset-Matching, Sector-Clustering. |
| **FinlyticSentiment** | Background Worker | `finlytic_sentimental` | FinBERT KI-Sentiment-Analyse (Deutsch & Englisch Webhooks), Exponentielle Zeit-Decay-Gewichtung ($\lambda = \ln(2)/\tau$). |
| **FinlyticFundamentals** | Playwright Worker | `finlytic_fundamentals`| Fundamentaldaten & Kennzahlen (KGV, ROE, Cashflow, Analysten-Ratings, Dividenden, Earnings-Kalender). |
| **FinlyticTechnicals** | Background Worker | `finlytic_ta` | Multi-Timeframe-Kerzen (15m, 1h, 1d), 10 Kernstrategien, 15 Pattern-Detektoren (SMC & Chart), Symmetrisches Scoring V2. |
| **FinlyticEngine** | Background Worker | `finlytic_engine` | Composite Opportunity Scorer (COS V2), Earnings/Dividenden-Sperren, n8n AI Reasoning Gate, Trade Lifecycle & Monitoring. |
| **FinlyticSimulation** | Background Worker | `finlytic_simulation` | Quantitative Backtesting-Engine, Replay-Runner (Anti-Lookahead), Zuverlässigkeitsmatrix, Slippage- & Gebührenmodellierung. |
| **FinlyticBot** | Background Worker | `finlytic_bot` | Automatisierte Orderausführung (1-2% Risikoregel), Alpaca Paper Trading (US-Equities) & Interner Synthetischer Ledger. |
| **FinlyticNotify** | Background Worker | `finlytic_notify` | Push-Benachrichtigungen via ntfy (Proposals, Trade-Events, Bot-Status, News). |
| **FinlyticBackend** | ASP.NET Core Kestrel | `finlytic_backend` | JWT-Authentifizierung, User- & Favoritenverwaltung, SignalR-Streaming, MQTT-Bridge. |
---
## 3. Einstellungen & Konfiguration (Settings)
### 3.1 Umgebungsvariablen (`compose.yaml` / `.env`)
| Variable | Standardwert | Beschreibung |
| :--- | :--- | :--- |
| `DB_HOST` | `OmniDB` | Hostname der PostgreSQL-Instanz |
| `DB_PORT` | `5432` | Port der PostgreSQL-Instanz |
| `DB_PASSWORD` | *(Pflichtfeld)* | Passwort für den PostgreSQL-Benutzer `admin` |
| `MQTT_HOST` | `host.docker.internal` | Hostname des MQTT-Brokers (z.B. Mosquitto) |
| `MQTT_PORT` | `4545` | Port des MQTT-Brokers |
| `JWT_SECRET_KEY` | *(Pflichtfeld, $\ge 32$ Zeichen)* | Signaturschlüssel für JWT-Token |
| `ADMIN_DEFAULT_PASSWORD` | *(Pflichtfeld)* | Initiales Passwort für den Standard-Admin |
| `FINLYTIC_DATA_ROOT` | `C:/Users/larsh/Documents/docker/finlytic` | Pfad für persistente Assets (Logos, Index) |
| `NTFY_BASE_URL` | `http://host.docker.internal:8080` | Basis-URL des ntfy-Push-Servers |
| `Webhooks__German` | `https://n8n.kleidukos.me/webhook/sentiment/de` | FinBERT Webhook für deutsche Artikel |
| `Webhooks__English` | `https://n8n.kleidukos.me/webhook/sentiment/en` | FinBERT Webhook für englische Artikel |
| `Ai__N8nValidationWebhookUrl`| `https://n8n.kleidukos.me/webhook/trade-validation` | n8n Webhook für AI-Trade-Validierung |
---
### 3.2 Dynamische Service-Einstellungen (`ISettingsService`)
Jeder Microservice verwaltet typisierte, zur Laufzeit änderbare Konfigurationswerte:
#### **FinlyticAssets (`SettingKeys.cs`)**
- `Logging.Channel.Assets` (bool, default: `true`): Logging für Asset-Scans
- `Logging.Channel.MQTT` (bool, default: `true`): Logging für MQTT-Verkehr
- `Logging.Channel.Health` (bool, default: `true`): Logging für Ping-Healthchecks
- `Logging.Channel.TradeRepublic` (bool, default: `true`): Logging für TR-WebSocket
- `TradeRepublic.WsReconnectIntervalSeconds` (int, default: `5`): Reconnect-Wartezeit
- `TradeRepublic.WsTimeoutSeconds` (int, default: `15`): Timeout für TR-Anfragen
- `Scanner.EnableAutoScan` (bool, default: `true`): Automatischer Asset-Sync aktiviert
- `Scanner.CurrentScanningType` (string, default: `"Stock"`): Aktueller Typ im Loop
- `Scanner.CurrentScanningPage` (int, default: `0`): Aktuelle Paginierungsseite (Recovery-Modus)
- `Scanner.FinishedInitialScan` (bool, default: `false`): Status des Initial-Scans
- `Scanner.BatchAssetUpdateDelay` (int, default: `0`): Pause zwischen Batches (Sekunden)
- `Scanner.AssetUpdateTypeDelay` (int, default: `0`): Pause zwischen Typen (Sekunden)
- `Scanner.TradeRepublicMaxRequestPageSize` (int, default: `50`): Batchgröße pro TR-Call
- `Scanner.CycleDelayMinutes` (int, default: `1440`): Wartezeit bis zum nächsten Vollscan (24h)
#### **FinlyticNews (`SettingKeys.cs`)**
- `Logging.Channel.News` (bool, default: `true`): News-Verarbeitungs-Logs
- `Logging.Channel.Scraper` (bool, default: `true`): Scraper-Adapter-Logs
- `Logging.Channel.Matcher` (bool, default: `true`): In-Memory Asset-Matcher-Logs
- `Logging.Channel.Deduplication` (bool, default: `true`): Duplikaterkennungs-Logs
- `Scraping.IntervalMinutes` (int, default: `15`): Scraping-Intervall
- `Scraping.MaxArticlesPerFeed` (int, default: `20`): Maximale Artikel pro Feed
- `Feature.EnableAutoScraping` (bool, default: `true`): Automatisches Scraping aktiv
- `Scraping.HttpTimeoutSeconds` (int, default: `30`): Timeout für HTTP/Playwright
- `Deduplication.TitleSimilarityThreshold` (double, default: `0.85`): Jaccard-Schwellenwert
- `Deduplication.SimHashMaxHammingDistance` (int, default: `3`): Max. SimHash-Bitdistanz
- `Deduplication.WindowDays` (int, default: `7`): Historienfenster für Duplikate
- `Matching.MinNameLength` (int, default: `3`): Minimale Zeichenlänge für Namensmatching
- `Matching.EnableSectorClustering` (bool, default: `true`): Sektorenprüfung bei Einzeltreffern
- `Matching.RequireFinancialContextForShortNames` (bool, default: `true`): Finanzkontext für kurze Namen
- `Data.ArticleRetentionDays` (int, default: `90`): Aufbewahrungsdauer für News
#### **FinlyticSentiment (`SettingKeys.cs`)**
- `Logging.Channel.Sentiment` (bool, default: `true`): Sentiment-Logs
- `Sentiment.GermanWebhookUrl` (string, default: `https://n8n.kleidukos.me/webhook/sentiment/de`)
- `Sentiment.EnglishWebhookUrl` (string, default: `https://n8n.kleidukos.me/webhook/sentiment/en`)
- `Sentiment.MinimumConfidenceThreshold` (double, default: `0.60`): Mindestkonfidenz
- `Sentiment.TimeDecayHalfLifeDays` (double, default: `7.0`): Halbwertszeit $\tau$ für Zeit-Decay
- `Sentiment.SentimentWindowDays` (int, default: `30`): Zeitfenster für Aggregation
- `Sentiment.AnalysisBatchSize` (int, default: `10`): Artikel pro Analysezyklus
- `Sentiment.PollIntervalSeconds` (int, default: `30`): Polling für neue Artikel
- `Sentiment.EnableAutoSentiment` (bool, default: `true`): Automatische Analyse aktiv
#### **FinlyticFundamentals (`SettingKeys.cs`)**
- `Logging.Channel.Fundamentals` (bool, default: `true`): Fundamentaldaten-Logs
- `Logging.Channel.HtmlScrapper` (bool, default: `true`): Playwright-Scraper-Logs
- `Logging.Channel.YahooClient` (bool, default: `true`): Yahoo Finance API-Logs
- `Feature.EnableHtmlFallback` (bool, default: `true`): HTML-Scraping falls API fehlt
- `Scraper.ForceHtmlFallback` (bool, default: `false`): HTML-Scraping erzwingen
- `Feature.AllowForceRefresh` (bool, default: `true`): Cache-Umgehung erlauben
- `Cache.FundamentalDataValidityDays` (int, default: `30`): Cache-Gültigkeit
#### **FinlyticTechnicals (`SettingKeys.cs`)**
- `Logging.Channel.TechnicalAnalysis` (bool, default: `true`): TA-Berechnungs-Logs
- `Indicators.RsiPeriod` (int, default: `14`): RSI-Periode
- `Indicators.MacdFastPeriod` (int, default: `12`): MACD Fast EMA
- `Indicators.MacdSlowPeriod` (int, default: `26`): MACD Slow EMA
- `Indicators.MacdSignalPeriod` (int, default: `9`): MACD Signal Line
- `Indicators.EmaShortPeriod` (int, default: `50`): EMA Short
- `Indicators.EmaLongPeriod` (int, default: `200`): EMA Long
- `Indicators.BollingerBandsPeriod` (int, default: `20`): Bollinger-Periode
- `Indicators.BollingerBandsStdDev` (double, default: `2.0`): Bollinger Standardabweichung
- `Indicators.AtrPeriod` (int, default: `14`): ATR-Periode
- `Cache.DurationMinutes` (int, default: `60`): Cache-Dauer
#### **FinlyticEngine (`EngineSettingKeys.cs`)**
- `Engine.MinCompositeScore` (decimal, default: `75.0`): Mindest-Gesamtscore für Proposals
- `Engine.WeightTechnical` (decimal, default: `0.45`): Gewichtung Technik (45%)
- `Engine.WeightSentiment` (decimal, default: `0.35`): Gewichtung Sentiment (35%)
- `Engine.WeightFundamental` (decimal, default: `0.20`): Gewichtung Fundamentaldaten (20%)
- `Engine.EarningsLockoutDays` (int, default: `2`): Vorlaufzeit vor Earnings (Score-Suppression)
- `Engine.DividendGateDays` (int, default: `1`): Vorlaufzeit vor Ex-Dividende
- `Engine.MinDerivativeLeverage` (decimal, default: `5.0`): Mindesthebel für KO-Derivate
- `Engine.TargetDefaultLeverage` (decimal, default: `7.0`): Zielhebel für KO-Derivate
- `Engine.KnockOutSafetyBufferPercent` (decimal, default: `2.0`): Sicherheitsabstand Barrier zu SL
- `Engine.AiValidationTimeoutSeconds` (int, default: `15`): Timeout für n8n AI-Validierung
- `Engine.EnableAiValidation` (bool, default: `true`): KI-Gate aktiv (sonst Fast-Pass)
- `Engine.EnablePaperTradingBot` (bool, default: `false`): Automatische Bot-Ausführung
- `Engine.PollingIntervalSeconds` (int, default: `120`): Poller-Intervall (Opportunity-Scan)
- `Engine.MonitoringIntervalSeconds` (int, default: `60`): Aktives Trade-Monitoring-Intervall
- `Engine.PollerMinScore` (decimal, default: `70.0`): Mindestscore für FTA-Abfrage
- `Engine.PollerTopPicksOnly` (bool, default: `true`): Nur Top-Picks abfragen ($\ge 75$)
- `Engine.PollerLimit` (int, default: `25`): Max. Setups pro Scan
- `Engine.ProposalValidityHours` (int, default: `24`): Gültigkeitsdauer eines Vorschlags (24h)
#### **FinlyticSimulation (`SimulationSettingKeys.cs`)**
- `Simulation.DefaultSlippagePercent` (decimal, default: `0.00`): Slippage deaktiviert (wird in Ordergebühren `DefaultOrderFeeEur` abgebildet)
- `Simulation.DefaultOrderFeeEur` (decimal, default: `1.00`): Ordergebühr pro Transaktion (1 €)
- `Simulation.DefaultStartingCapital` (decimal, default: `10000.00`): Startkapital für Backtests
- `Simulation.MinSampleTradesForApproval` (int, default: `5`): Mindest-Trades für Matrix-Zulassung
- `Simulation.HighProfitFactorThreshold` (decimal, default: `1.60`): PF für Score-Bonus (+15 Pkt)
- `Simulation.LowProfitFactorThreshold` (decimal, default: `1.00`): PF für Veto-Sperre
- `Simulation.KnockOutBarrierBufferPercent` (decimal, default: `2.0`): KO-Barrier-Simulation
- `Simulation.DefaultTrailingStopPercent` (decimal, default: `3.0`): Fallback-Trailing-Stop
- `Simulation.EnableScheduledMatrixRecompute` (bool, default: `true`): Periodische Matrix-Neuberechnung
- `Simulation.MatrixRecomputeIntervalHours` (int, default: `24`): Matrix-Stale-Schwelle (24h)
- `Simulation.MatrixRecomputeCheckIntervalMinutes` (int, default: `60`): Recompute-Check-Intervall
#### **FinlyticBot (`BotSettingKeys.cs`)**
- `Alpaca.KeyId` (string): Alpaca API Key
- `Alpaca.SecretKey` (string): Alpaca Secret Key
- `Alpaca.IsPaper` (bool, default: `true`): Alpaca Paper vs. Live
- `Bot.EnableAutoExecution` (bool, default: `true`): Automatische Ausführung aktiv
- `Bot.RiskPerTradePercent` (decimal, default: `1.0`): 1% Risiko pro Trade bezogen auf Gesamtkapital
- `Bot.MaxPositionAllocationPercent` (decimal, default: `20.0`): Max. 20% Kapital pro Einzelposition
- `Bot.MaxConcurrentPositions` (int, default: `5`): Max. 5 offene Positionen gleichzeitig
- `Bot.DailyLossLimitPercent` (decimal, default: `3.0`): Täglicher Verluststopp (3%)
- `Bot.MonitoringIntervalSeconds` (int, default: `15`): Bot-Positionsüberwachung (15s)
- `Bot.SyntheticBaseCapitalEur` (decimal, default: `50000.0`): Startkapital Synthetischer Ledger
#### **FinlyticNotify (`NotifySettingKeys.cs`)**
- `Ntfy.BaseUrl` (string, default: `http://localhost:8080`)
- `Ntfy.TopicPrefix` (string, default: `finlytic`)
- `Ntfy.BroadcastChannel` (string, default: `broadcast`)
- `Ntfy.NewsChannel` (string, default: `news`)
- `Ntfy.DefaultUsername` (string, default: `admin`)
- `Ntfy.MinProposalScore` (decimal, default: `70.0`)
- `Ntfy.NotifyOnProposals` (bool, default: `true`)
- `Ntfy.NotifyOnTradeUpdates` (bool, default: `true`)
- `Ntfy.NotifyOnBotTrades` (bool, default: `true`)
- `Ntfy.NotifyOnNews` (bool, default: `true`)
- `Ntfy.ClickBaseUrl` (string, default: `http://localhost:3000`)
---
## 4. Mathematische, Technische & Finanzielle Formeln
### 4.1 Technische Indikatoren (`TechnicalIndicatorsEngine.cs`)
#### 1. Simple Moving Average (SMA)
$$\text{SMA}_n = \frac{1}{n} \sum_{i=0}^{n-1} P_{t-i}$$
#### 2. Exponential Moving Average (EMA)
Glättungsfaktor $k$:
$$k = \frac{2}{n + 1}$$
$$\text{EMA}_t = (P_t \cdot k) + (\text{EMA}_{t-1} \cdot (1 - k))$$
*(Initialisierung über SMA der ersten $n$ Kerzen)*
#### 3. Relative Strength Index (RSI - Wilder's Smoothing)
Gewinne $U_t = \max(0, P_t - P_{t-1})$, Verluste $D_t = \max(0, P_{t-1} - P_t)$
$$\overline{U}_t = \frac{\overline{U}_{t-1} \cdot (n-1) + U_t}{n}, \quad \overline{D}_t = \frac{\overline{D}_{t-1} \cdot (n-1) + D_t}{n}$$
$$\text{RS} = \frac{\overline{U}_t}{\overline{D}_t}, \quad \text{RSI} = 100 - \frac{100}{1 + \text{RS}}$$
#### 4. Average True Range (ATR)
$$\text{TR}_t = \max \left( H_t - L_t, \, |H_t - C_{t-1}|, \, |L_t - C_{t-1}| \right)$$
$$\text{ATR}_n = \frac{1}{n} \sum_{i=0}^{n-1} \text{TR}_{t-i}$$
#### 5. Moving Average Convergence Divergence (MACD)
$$\text{MACD Line} = \text{EMA}_{12}(P) - \text{EMA}_{26}(P)$$
$$\text{Signal Line} = \text{EMA}_9(\text{MACD Line})$$
$$\text{Histogram} = \text{MACD Line} - \text{Signal Line}$$
#### 6. Bollinger Bands & %B
$$\text{Middle Band} = \text{SMA}_{20}(P)$$
$$\sigma = \sqrt{\frac{1}{20} \sum_{i=0}^{19} (P_{t-i} - \text{Middle Band})^2}$$
$$\text{Upper Band} = \text{Middle Band} + 2\sigma, \quad \text{Lower Band} = \text{Middle Band} - 2\sigma$$
$$\text{Bandwidth} = \frac{\text{Upper} - \text{Lower}}{\text{Middle}} \cdot 100, \quad \%B = \frac{P_t - \text{Lower}}{\text{Upper} - \text{Lower}}$$
#### 7. Keltner Channels & Volatility Squeeze
$$\text{KC Middle} = \text{EMA}_{20}(P), \quad \text{KC Upper} = \text{EMA}_{20} + 1.5 \cdot \text{ATR}_{20}, \quad \text{KC Lower} = \text{EMA}_{20} - 1.5 \cdot \text{ATR}_{20}$$
$$\text{Squeeze On} \iff \text{BB Lower} > \text{KC Lower} \quad \text{UND} \quad \text{BB Upper} < \text{KC Upper}$$
#### 8. SuperTrend
$$\text{HL2} = \frac{H_t + L_t}{2}$$
$$\text{Upper Band} = \text{HL2} + 3.0 \cdot \text{ATR}_{10}, \quad \text{Lower Band} = \text{HL2} - 3.0 \cdot \text{ATR}_{10}$$
#### 9. Average Directional Index (ADX / DMI)
$$+\text{DM} = \begin{cases} H_t - H_{t-1} & \text{falls } H_t - H_{t-1} > L_{t-1} - L_t \text{ und } > 0 \\ 0 & \text{sonst} \end{cases}$$
$$-\text{DM} = \begin{cases} L_{t-1} - L_t & \text{falls } L_{t-1} - L_t > H_t - H_{t-1} \text{ und } > 0 \\ 0 & \text{sonst} \end{cases}$$
$$+\text{DI}_{14} = \frac{\sum +\text{DM}}{\sum \text{TR}} \cdot 100, \quad -\text{DI}_{14} = \frac{\sum -\text{DM}}{\sum \text{TR}} \cdot 100$$
$$\text{DX} = \frac{|+\text{DI} - -\text{DI}|}{+\text{DI} + -\text{DI}} \cdot 100, \quad \text{ADX} = \text{SMA}_{14}(\text{DX})$$
#### 10. Volume Weighted Average Price (VWAP)
$$\text{VWAP} = \frac{\sum_{i=1}^N \left( \frac{H_i + L_i + C_i}{3} \cdot V_i \right)}{\sum_{i=1}^N V_i}$$
---
### 4.2 Symmetrisches Technisches Scoring V2 (`TechnicalScoringEngineV2.cs`)
$$\text{FinalScore} = \text{Clamp}\Big( (0.35 \cdot S_{\text{Ind}}) + (0.35 \cdot S_{\text{Pattern}}) + (0.30 \cdot S_{\text{BaseStrategy}}), \; 0, \; 100 \Big)$$
#### Indikator-Confluence ($S_{\text{Ind}}$ Basis: 50 Pkt):
- **Buy (Long)**:
- $\text{EMA}_{20} > \text{EMA}_{50} \implies +15$ Pkt
- $\text{RSI}_{14} \in [45, 65] \implies +15$ Pkt
- $\text{ADX}_{14} \ge 25 \implies +10$ Pkt
- $P > \text{VWAP} \text{ oder } \text{EMA}_{20} > \text{VWAP} \implies +10$ Pkt
- **Sell (Short)**:
- $\text{EMA}_{20} < \text{EMA}_{50} \implies +15$ Pkt
- $\text{RSI}_{14} \in [35, 55] \implies +15$ Pkt
- $\text{ADX}_{14} \ge 25 \implies +10$ Pkt
- $P < \text{VWAP} \text{ oder } \text{EMA}_{20} < \text{VWAP} \implies +10$ Pkt
---
### 4.3 Exponentielles Zeit-Decay-Sentiment (`SentimentDbService.cs`)
Jeder Artikel $i$ hat Alter $\Delta t_i = \text{Now} - t_{\text{published}}$ in Tagen und FinBERT-Konfidenz $C_i$.
Abklingkonstante $\lambda$:
$$\lambda = \frac{\ln(2)}{\tau} \quad (\tau = \text{HalfLifeDays}, \text{ Standard: } 7.0)$$
Gewicht des Artikels $w_i$:
$$w_i = \max(0.01, C_i) \cdot e^{-\lambda \cdot \Delta t_i}$$
Aggregierter gewichteter Sentiment-Score $S_{\text{weighted}} \in [-1.0, +1.0]$:
$$S_{\text{weighted}} = \frac{\sum_{i=1}^N (w_i \cdot \text{CompoundScore}_i)}{\sum_{i=1}^N w_i}$$
---
### 4.4 Composite Opportunity Score (COS V2) (`CompositeOpportunityScorerV2.cs`)
Gewichtete Faktoren:
- Technischer Score $S_{\text{Tech}} \in [0, 100]$ (Gewicht: $w_{\text{Tech}} = 0.45$)
- Sentiment Score $S_{\text{Sent}} \in [0, 100]$ (Gewicht: $w_{\text{Sent}} = 0.35$):
- Für Buy: $S_{\text{Sent}} = \frac{S_{\text{weighted}} + 1}{2} \cdot 100$
- Für Sell: $S_{\text{Sent}} = \frac{1 - S_{\text{weighted}}}{2} \cdot 100$
- Fundamentaler Score $S_{\text{Fund}} \in [0, 100]$ (Gewicht: $w_{\text{Fund}} = 0.20$):
- Richtungsabhängige Bewertung von KGV, ROE, Debt/Equity, Consensus-Rating und Short-Interest.
- Simulations-Matrix-Bonus: $B_{\text{Sim}} = +15$ falls $\text{PF} \ge 1.60$, Veto-Multiplikator $M_{\text{Veto}} = 0.20$ falls $\text{PF} < 1.00$.
- Sperr-Multiplikatoren:
- Earnings-Sperre: $M_{\text{Earnings}} = 0.15$ falls $\text{Tage zu Earnings} \le 2$.
- Dividenden-Sperre: $M_{\text{Dividend}} = 0.50$ falls $\text{Tage zu Ex-Dividende} \le 1$.
$$\text{RawScore} = (w_{\text{Tech}} \cdot S_{\text{Tech}}) + (w_{\text{Sent}} \cdot S_{\text{Sent}}) + (w_{\text{Fund}} \cdot S_{\text{Fund}}) + B_{\text{Sim}}$$
$$\text{COS} = \text{Clamp}\Big( \text{RawScore} \cdot M_{\text{Earnings}} \cdot M_{\text{Dividend}} \cdot M_{\text{Veto}}, \; 0, \; 100 \Big)$$
---
### 4.5 Positionsgrößenbestimmung & Risikomodell (1-2% Regel) (`BotOrderExecutor.cs`)
Gesamtes Kontokapital $E$, Risiko pro Trade $R_{\%} = 1.0\%$, Maximalallokation $A_{\%} = 20.0\%$.
$$\text{MaxRiskCapital} = E \cdot \frac{R_{\%}}{100}$$
$$\text{UnitRisk} = |\text{EntryPrice} - \text{StopLossPrice}|$$
Berechnete Stückzahl $Q_{\text{calc}}$:
$$Q_{\text{calc}} = \frac{\text{MaxRiskCapital}}{\text{UnitRisk}}$$
Allokations-Deckelung:
$$Q_{\text{max}} = \frac{E \cdot \frac{A_{\%}}{100}}{\text{EntryPrice}}$$
$$Q_{\text{final}} = \max\Big(1, \, \text{Round}\big(\min(Q_{\text{calc}}, Q_{\text{max}})\big)\Big)$$
---
### 4.6 Quantitative Simulations- & Performancemetriken (`VirtualBacktestBroker.cs`)
- **Win Rate (WR)**:
$$\text{WR} = \frac{N_{\text{Wins}}}{N_{\text{Trades}}} \cdot 100$$
- **Profit Factor (PF)**:
$$\text{PF} = \frac{\sum \text{Gewinne}}{\sum |\text{Verluste}|}$$
- **Erwartungswert (Expectancy in €)**:
$$\text{Expectancy} = \left(\frac{\text{WR}}{100} \cdot \overline{\text{Win}}\right) - \left(\left(1 - \frac{\text{WR}}{100}\right) \cdot \overline{\text{Loss}}\right)$$
- **Annualisierte Sharpe Ratio**:
$$\overline{R} = \frac{1}{N}\sum R_i, \quad \sigma_R = \sqrt{\frac{1}{N-1}\sum (R_i - \overline{R})^2}$$
$$\text{Sharpe Ratio} = \frac{\overline{R}}{\sigma_R} \cdot \sqrt{252}$$
- **R-Multiple**:
$$R_{\text{mult}} = \frac{\text{Realisierter PnL}}{\text{UnitRisk} \cdot \text{Menge}}$$
- **Max Adverse / Favorable Excursion (MAE / MFE)**:
$$\text{MAE}_{\text{Long}} = \frac{P_{\text{Entry}} - P_{\text{Min}}}{P_{\text{Entry}}} \cdot 100, \quad \text{MFE}_{\text{Long}} = \frac{P_{\text{Max}} - P_{\text{Entry}}}{P_{\text{Entry}}} \cdot 100$$
---
## 5. Schnittstellen & Endpunkte
### 5.1 MQTT RPC-Kanäle
Schema: Request auf `services/request/{channel}/{correlationId}`, Response auf `services/response/{channel}/{correlationId}`.
| Kanalname (`MqttTopics.Channels`) | Betreuender Service | Request-DTO | Response-DTO | Beschreibung |
| :--- | :--- | :--- | :--- | :--- |
| `health_Ping` | *(Alle Services)* | `object` | `ServiceHealthResponse` | Liveness-Check pro Service |
| `assets_Get` | FinlyticAssets | `GetValidAssetRequest` | `List<AssetDto>` | Stammdaten für ISIN auflösen |
| `assets_GetDiscovery` | FinlyticAssets | `GetDiscoveryAssetsRequest` | `List<AssetDto>` | Kuratierte Discovery-Assets |
| `assets_GetDerivatives` | FinlyticAssets | `GetDerivativesRequest` | `List<DerivativeDto>` | KO-Derivate nach Hebel/Typ suchen |
| `tr_GetLivePrice` | FinlyticAssets | `IsinRequest` | `LivePriceDto?` | Realtime-Kurs via Trade Republic |
| `assets_settings_GetAll` | FinlyticAssets | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `assets_settings_Update` | FinlyticAssets | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `news_Get` | FinlyticNews | `DailyNewsRequest` | `List<NewsArticleDto>` | Paginierte/gefilterte News |
| `news_GetById` | FinlyticNews | `ArticleRequest` | `NewsArticleDto?` | Einzelartikel nach ID |
| `news_GetPending` | FinlyticNews | `object` | `List<NewsArticleDto>` | Artikel zur Sentiment-Analyse |
| `news_UpdateStatus` | FinlyticNews | `UpdateNewsStatusRequest` | `UpdateNewsStatusResponse` | Artikel-Status aktualisieren |
| `news_settings_GetAll` | FinlyticNews | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `news_settings_Update` | FinlyticNews | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `sentiment_GetIsin` | FinlyticSentiment | `GetSentimentByIsinRequest` | `IsinSentimentSummaryDto?` | Aggregiertes Sentiment für ISIN |
| `sentiment_GetSector` | FinlyticSentiment | `GetSectorSentimentRequest` | `SectorSentimentSummaryDto?`| Sektoren-Sentiment |
| `sentiment_GetArticle` | FinlyticSentiment | `ArticleRequest` | `IsinAnalysisEntry?` | FinBERT-Ergebnis für Artikel |
| `sentiment_GetAll` | FinlyticSentiment | `PaginatedRequest` | `List<CompanySentimentSummaryEntity>` | Alle Unternehmens-Sentiments |
| `sentiment_Analyze` | FinlyticSentiment | `JsonElement` / `NewsArticleDto` | `IsinAnalysisEntry?` | Ad-hoc FinBERT-Analyse |
| `sentiment_settings_GetAll` | FinlyticSentiment | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `sentiment_settings_Update` | FinlyticSentiment | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `fundamentals_Get` | FinlyticFundamentals | `IsinRequest` / `GetFundamentalsRequest` | `AssetFundamentalsDto?` | Fundamentaldaten & Kennzahlen |
| `events_GetAll` | FinlyticFundamentals | `object` | `List<CorporateEventDto>` | Alle Termine/Events |
| `events_GetByMonth` | FinlyticFundamentals | `GetEventsByMonthRequest` | `List<CorporateEventDto>` | Monatliche Termine/Earnings |
| `fundamentals_settings_GetAll`| FinlyticFundamentals | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `fundamentals_settings_Update`| FinlyticFundamentals | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `ta_GetAnalysis` | FinlyticTechnicals | `IsinRequest` | `TechnicalAnalysisDto?` | Komplette TA inkl. Indikatoren |
| `ta_GetSetupsForIsin` | FinlyticTechnicals | `IsinRequest` | `List<StrategyResultDto>` | Aktive Setups für eine ISIN |
| `ta_GetSetups` | FinlyticTechnicals | `GetSetupsRequest` | `List<StrategyResultDto>` | Universe-weite Setups/Top-Picks |
| `ta_GetCandles` | FinlyticTechnicals | `GetCandlesRequest` | `IReadOnlyList<CandleDto>` | Kerzen nach Timeframe |
| `ta_GetWatchlist` | FinlyticTechnicals | `object` | `List<WatchlistEntryDto>` | Monitorte Universe-Assets |
| `ta_GetRecentSetupHistory` | FinlyticTechnicals | `GetRecentSetupHistoryRequest` | `List<StrategyResultDto>` | Historische Setup-Scores |
| `ta_settings_GetAll` | FinlyticTechnicals | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `ta_settings_Update` | FinlyticTechnicals | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `engine_GetProposals` | FinlyticEngine | `GetProposalsRequest` | `List<TradeProposalDto>` | Aktive Trade-Vorschläge |
| `engine_GetTrades` | FinlyticEngine | `GetTradesRequest` | `List<ActiveTradeDto>` | Aktive Benutzertrades |
| `engine_EvaluateIsin` | FinlyticEngine | `EvaluateIsinRequest` | `AssetEvaluationResultDto?`| Manuelle ISIN-Evaluierung |
| `engine_AddFill` | FinlyticEngine | `AddFillRequest` | `ActiveTradeDto?` | Teil-/Vollausführung buchen |
| `engine_UpdateStopLoss` | FinlyticEngine | `UpdateStopLossRequest` | `ActiveTradeDto?` | Stop-Loss anpassen |
| `engine_CloseTrade` | FinlyticEngine | `CloseTradeRequest` | `ActiveTradeDto?` | Trade schließen |
| `engine_AcceptProposal` | FinlyticEngine | `AcceptTradeProposalRequest` | `ActiveTradeDto?` | Vorschlag als Trade annehmen |
| `engine_CreateManualTrade` | FinlyticEngine | `CreateManualTradeRequest` | `ActiveTradeDto?` | Manuellen Trade eröffnen |
| `engine_GetEvaluationHistory` | FinlyticEngine | `GetEvaluationHistoryRequest` | `GetEvaluationHistoryResponse` | Admin-Evaluierungs-Historie |
| `engine_settings_GetAll` | FinlyticEngine | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `engine_settings_Update` | FinlyticEngine | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `sim_RunBacktest` | FinlyticSimulation | `BacktestRequestDto` | `BacktestReportDto` | Quantitativen Backtest starten |
| `sim_GetReliability` | FinlyticSimulation | `GetReliabilityRequest` | `StrategyAssetReliabilityDto?`| Zuverlässigkeit für Setup |
| `sim_GetMatrixForAsset` | FinlyticSimulation | `IsinRequest` | `List<StrategyAssetReliabilityDto>`| Komplette Asset-Matrix |
| `sim_GetBacktestHistory` | FinlyticSimulation | `GetBacktestHistoryRequest` | `GetBacktestHistoryResponse` | Historische Backtest-Läufe |
| `sim_GetBacktestRunDetail` | FinlyticSimulation | `RunIdRequest` | `BacktestReportDto?` | Detailbericht eines Backtests |
| `sim_GetStrategyParameters` | FinlyticSimulation | `GetStrategyParametersRequest` | `StrategyParameterProfileDto?`| Gespeicherte TA-Parameter |
| `sim_SaveStrategyParameters` | FinlyticSimulation | `SaveStrategyParametersRequest`| `StrategyParameterProfileDto` | TA-Parameter speichern |
| `sim_settings_GetAll` | FinlyticSimulation | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `sim_settings_Update` | FinlyticSimulation | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `bot_GetStatus` | FinlyticBot | `object` | `BotStatusDto` | Bot-Status & Venue |
| `bot_GetPositions` | FinlyticBot | `object` | `List<BotTradeOrderDto>` | Offene Bot-Positionen |
| `bot_GetSummary` | FinlyticBot | `object` | `AccountSummaryDto` | Kontostand & PnL |
| `bot_ExecuteProposal` | FinlyticBot | `ExecuteProposalRequest` | `BotTradeOrderDto?` | Order für Proposal aufgeben |
| `bot_PanicClose` | FinlyticBot | `object` | `PanicCloseResultDto` | Notfall-Schließung aller Positionen |
| `bot_settings_GetAll` | FinlyticBot | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `bot_settings_Update` | FinlyticBot | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `notify_settings_GetAll` | FinlyticNotify | `object` | `List<DynamicSettingDto>` | Einstellungen abfragen |
| `notify_settings_Update` | FinlyticNotify | `Dictionary<string, object?>` | `List<DynamicSettingDto>` | Einstellungen aktualisieren |
| `backend_GetAggregatedFavorites`| FinlyticBackend | `object` | `List<string>` | Alle Benutzer-Favoriten-ISINs |
| `backend_GetUsername` | FinlyticBackend | `UserIdRequest` | `string?` | Benutzernamen nach GUID |
---
### 5.2 MQTT Pub/Sub Event-Topics
| Topic | Publisher | Konsumenten | Payload-Typ | Beschreibung |
| :--- | :--- | :--- | :--- | :--- |
| `services/news/completed` | FinlyticNews | FinlyticSentiment, Backend | `NewsArticleDto` | Neuer fertig verarbeiteter Artikel |
| `finlytic/news/stream/{isin}` | FinlyticNews | BackendMqttBridge | `NewsArticleDto` | ISIN-spezifischer News-Stream |
| `finlytic/sentiment/stream/{isin}`| FinlyticSentiment | FinlyticTechnicals, Backend | `IsinSentimentSummaryDto` | Aktualisiertes Sentiment |
| `finlytic/engine/proposals/created`| FinlyticEngine | FinlyticBot, FinlyticNotify, Backend | `TradeProposalDto` | Neuer Trade-Vorschlag erstellt |
| `finlytic/engine/trades/status_changed`| FinlyticEngine| FinlyticNotify, Backend | `ActiveTradeDto` | Trade-Statusänderung (TP/SL/Close) |
| `finlytic/bot/trades/stream` | FinlyticBot | FinlyticNotify, Backend | `BotTradeOrderDto` | Bot-Order-Lifecycle-Event |
| `finlytic/logs/{service}` | *(Alle Services)* | BackendMqttBridge | `LogMessageDto` | Strukturierter Service-Logstream |
---
### 5.3 REST API Endpunkte (`FinlyticBackend`)
Alle Endpunkte erfordern `Authorization: Bearer <JWT>` (außer Login und Health).
#### 1. Authentifizierung & Benutzer (`/api/v1/auth`, `/api/v1/user`, `/api/v1/admin`)
- `POST /api/v1/auth/login` `[AllowAnonymous]`: Login mit Username/Passwort $\to$ JWT Token & User-Objekt.
- `POST /api/v1/auth/change-initial-password`: Ändern des Initialpassworts bei `RequiresPasswordChange`.
- `POST /api/v1/user/fcm-token`: Hinterlegen des Firebase Cloud Messaging Push-Tokens.
- `GET /api/v1/user/me`: Profil des aktuell angemeldeten Benutzers abrufen.
- `GET /api/v1/admin/users` `[Roles: Admin]`: Benutzerliste.
- `POST /api/v1/admin/users` `[Roles: Admin]`: Neuen Benutzer anlegen.
- `PUT /api/v1/admin/users/{id}` `[Roles: Admin]`: Benutzer bearbeiten (Rolle, Status).
- `DELETE /api/v1/admin/users/{id}` `[Roles: Admin]`: Benutzer deaktivieren/löschen.
- `POST /api/v1/admin/users/{id}/reset-password` `[Roles: Admin]`: Passwort zurücksetzen.
#### 2. Assets & Discovery (`/api/v1/assets`)
- `GET /api/v1/assets/search?q={query}`: Volltextsuche nach Name/ISIN im lokalen Index.
- `GET /api/v1/assets/discovery?limit={limit}`: Kuratierte Trend-/Discovery-Assets.
- `GET /api/v1/assets/{isin}/fundamentals`: Fundamentaldaten, KGV, Events.
- `GET /api/v1/assets/{isin}/technicals`: TA-Indikatoren, Kerzen, Setups.
- `GET /api/v1/assets/{isin}/live`: Trade Republic Realtime-Tick (Bid/Ask/Last).
- `GET /api/v1/assets/{isin}/derivatives?optionType={long|short}&targetLeverage={x}`: Passende KO-Zertifikate.
- `GET /api/v1/logo/{isin}` `[AllowAnonymous]`: Lokales SVG-Logo ausliefern.
#### 3. Analyse & Engine (`/api/v1/analyze`, `/api/v1/engine`)
- `POST /api/v1/analyze/manual`: Ad-hoc Auswertung einer ISIN (TA, Sentiment, KI-Gate).
- `GET /api/v1/analyze/proposals`: Vorschläge abrufen.
- `GET /api/v1/engine/proposals?onlyActive={bool}&limit={limit}`: Aktive Vorschläge.
- `GET /api/v1/engine/trades?mode={Manual|Bot}`: Trades des eingeloggten Users.
- `POST /api/v1/engine/evaluate`: Evaluierungs-Trigger für ISIN.
- `POST /api/v1/engine/trades/{id}/fills`: Fill buchen.
- `PUT /api/v1/engine/trades/{id}/stoploss`: SL-Anpassung.
- `POST /api/v1/engine/trades/{id}/close`: Trade schließen.
#### 4. Benutzer-Trades (`/api/v1/user/trades`)
- `GET /api/v1/user/trades`: Eigene aktive & historische Trades.
- `POST /api/v1/user/trades/accept`: Vorschlag verbindlich annehmen.
- `POST /api/v1/user/trades/manual`: Eigenen Trade ohne Vorschlag eröffnen.
- `POST /api/v1/user/trades/{id}/close`: Eigenen Trade manuell schließen.
#### 5. Favoriten & Präferenzen (`/api/v1/user/favorites`, `/api/v1/user/preferences`)
- `GET /api/v1/user/favorites`: Favoritenliste mit Live-Preisen & Tagesänderung.
- `POST /api/v1/user/favorites/{symbol}`: Asset zu Favoriten hinzufügen.
- `POST /api/v1/user/favorites/{symbol}/ticker`: Ticker-Symbol zuweisen.
- `DELETE /api/v1/user/favorites/{symbol}`: Asset aus Favoriten entfernen.
- `GET /api/v1/user/preferences`: UI-Präferenzen (Theme, Layout).
- `PUT /api/v1/user/preferences/theme`: Theme anpassen.
#### 6. Backtesting & Simulation (`/api/v1/simulation`)
- `POST /api/v1/simulation/run`: Quantitativen Backtest starten.
- `GET /api/v1/simulation/matrix/{isin}`: Zuverlässigkeitsmatrix für ISIN.
- `GET /api/v1/simulation/history/{isin}`: Historische Backtests für ISIN.
- `GET /api/v1/simulation/history/run/{runId}`: Vollständiger Backtest-Report.
- `GET /api/v1/simulation/parameters/{isin}/{strategyKey}`: Gespeicherte Strategie-Parameter.
- `POST /api/v1/simulation/parameters`: Parameterprofil speichern.
#### 7. Bot & Paper-Trading (`/api/v1/bot`)
- `GET /api/v1/bot/status`: Bot-Status & Venue.
- `GET /api/v1/bot/positions/active`: Offene Bot-Positionen.
- `GET /api/v1/bot/portfolio/summary`: Kontostand & PnL.
- `POST /api/v1/bot/orders/execute`: Manuelle Order über Bot abschicken.
- `POST /api/v1/bot/orders/panic-close`: Notfall-Schließung.
- `POST /api/v1/bot/settings/update`: Bot-Einstellungen aktualisieren.
#### 8. News & Kalender (`/api/v1/news`, `/api/v1/calendar`)
- `GET /api/v1/news?limit={limit}&offset={offset}&isin={isin}&status={status}`: Gefilterte News.
- `GET /api/v1/calendar/events/{year}/{month}`: Corporate Events & Earnings.
#### 9. Admin-System (`/api/v1/admin/settings`, `/api/v1/admin/evaluations`)
- `GET /api/v1/admin/settings`: Einstellungen aller Services.
- `GET /api/v1/admin/settings/{serviceName}`: Einstellungen eines Services.
- `PUT /api/v1/admin/settings/{serviceName}`: Einstellungen dynamisch ändern.
- `GET /api/v1/admin/settings/health`: Health-Status aller Services.
- `GET /api/v1/admin/settings/logs/{serviceName}`: Letzte 250 Ringpuffer-Logs.
- `GET /api/v1/admin/evaluations`: Evaluierungs-Historie ("Warum kein Proposal?").
- `GET /api/v1/admin/evaluations/watchlist`: Gescannte Watchlist-Assets.
- `GET /api/v1/admin/evaluations/watchlist/{isin}/history`: Setup-Verlauf eines Assets.
---
### 5.4 SignalR Hubs & Methoden
Verbindung über `/hubs/{hubname}?access_token=<JWT>`.
| Hub-Route | Server-Methoden | Client-Events (Callbacks) | Zweck |
| :--- | :--- | :--- | :--- |
| `/hubs/trade-stream` | `SubscribeToAsset(isin)`<br>`UnsubscribeFromAsset(isin)` | `ReceiveTradeProposal(proposal)`<br>`ReceiveTradeUpdate(trade)` | Realtime-Updates zu Proposals & Trades |
| `/hubs/news` | `SubscribeToIsin(isin)` | `ReceiveNewsArticle(article)` | Neue gescrapte/analysierte News |
| `/hubs/health` | *(Keine)* | `ReceiveServiceHealth(health)` | Live-Healthcheck der Services |
| `/hubs/favorites-prices`| *(Keine)* | `ReceivePriceUpdate(isin, price, change)`| Realtime-Kursupdates der Favoriten (15s Takt) |
| `/hubs/logs` | *(Keine)* | `ReceiveLogMessage(logDto)` | Admin Live-Logstream |
---
## 6. Detaillierte Funktionsweise & Datenfluss
### 6.1 End-to-End Opportunity- & Trade-Lifecycle
```
1. DATA INGESTION
├── FinlyticNews: RSS/Scraping -> SimHash Deduplication -> In-Memory Matcher -> MQTT "services/news/completed"
├── FinlyticSentiment: FinBERT Webhook -> Time-Decay DB Update -> MQTT "finlytic/sentiment/stream/{isin}"
└── FinlyticTechnicals: Trade Republic & Yahoo Ticks -> Resampler -> Indicator Math (RSI, EMA, Squeeze, etc.)
2. TECHNICAL SCANNING (FinlyticTechnicals)
├── MultiTimeframeCandleAggregator aktualisiert Ringpuffer (15m, 1h, 1d)
├── 15 Pattern-Detektoren identifizieren FVG, OrderBlocks, DoubleBottom, Liquidity Sweeps
├── 10 CoreStrategies evaluieren Signale & erzeugen Exit-Pläne (TP1, TP2, Trailing-Stop, Break-Even)
└── TechnicalScoringEngineV2 berechnet Confluence-Score (Indikatoren + Patterns + Strategie)
3. ENGINE EVALUATION (FinlyticEngine)
├── OpportunityPollerBackgroundService pollt Top-Picks (Score >= 70)
├── CompositeOpportunityScorerV2 berechnet COS (Tech 45%, Sent 35%, Fund 20%)
├── Filter-Gates: EarningsLockout (2 Tage), DividendGate (1 Tag), Simulation Matrix Veto
├── AiReasoningGateService sendet strukturierten Context an n8n AI-Webhook
└── KnockOutDerivativeResolver matcht Hebel & Safety-Buffer -> Proposal persistiert & publiziert
4. EXECUTION & BOT (FinlyticBot / FinlyticApp)
├── FinlyticApp: User sieht Proposal im UI, klickt "Accept" -> Trade eröffnet
├── FinlyticBot: Falls AutoExecution aktiv -> 1-2% Risikosizing -> Orderausführung (Alpaca/Ledger)
└── FinlyticNotify: ntfy Push-Notification an Smartphone/Desktop
5. ACTIVE MONITORING & EXITS
├── ActiveTradeMonitoringBackgroundService (Engine) & BotTradeLifecycleBackgroundService (Bot)
├── Regelmäßige Kursabfrage (15s - 60s)
├── Bei TP1: Teilverkauf (50%) & Verschieben des Stop-Loss auf Break-Even (gebührenbereinigt)
├── Bei TP2: Teilverkauf (30%) & Aktivierung des ATR-Trailing-Stops für verbleibende 20%
└── Bei Stop-Loss / Knock-Out / MaxBars: Positionsschließung & PnL-Verbuchung
```
---
## 7. Frontend-Architektur (FinlyticApp)
- **Technologie**: Flutter (Dart) mit Web- und Mobile-Unterstützung.
- **State Management**: `flutter_bloc` (`BlocProvider`, `BlocBuilder`, `BlocConsumer`, `Cubit`).
- **Netzwerk & Security**:
- Zentraler `Dio`-Client mit `AuthInterceptor`.
- Injiziert automatisch `Authorization: Bearer <token>` in jeden Request.
- **Automatischer Logout**: Fängt `401 Unauthorized` und `403 Forbidden` zentral ab, löscht Secure Storage und leitet sofort auf den Login-Bildschirm um.
- **Realtime-Kommunikation**: `SignalRService` mit automatischem Reconnect und Event-Subskriptionen (`trade-stream`, `news`, `health`, `favorites-prices`, `logs`).
- **Feature-Struktur**:
- `features/auth`: Login, Passwortänderung.
- `features/dashboard`: Schnellübersicht, aktive Trades, Markttrends.
- `features/discovery`: Top-Assets, Scanner-Ergebnisse.
- `features/asset_detail`: Interaktiver Chart, Multi-Timeframe-Indikatoren, Fundamentaldaten, Sentiment-Historie, Derivate-Selektor.
- `features/proposals`: Trade-Vorschläge mit detaillierter KI-Begründung, Setup-Chart und Direkt-Annahme.
- `features/trades`: Eigene Positionen, TP/SL-Visualisierung, manuelles Schließen.
- `features/bot`: Bot-Positionen, Performance-Graphen, Kontostand, Panic-Close-Button.
- `features/simulation`: Backtest-Runner, Equity-Kurven, Strategie-Zuverlässigkeitsmatrix, Parameter-Tuning.
- `features/news`: Live-Newsfeed mit Sentiment-Badges und Filter nach Asset.
- `features/calendar`: Earnings- und Corporate-Events-Kalender.
- `features/favorites`: Realtime-Watchlist mit Kurs-Ticker.
- `features/admin`: Dynamische Service-Settings, Live-Log-Konsole, System-Health, Benutzerverwaltung, Evaluierungs-Historie ("Warum kein Proposal?").
+24 -61
View File
@@ -217,25 +217,37 @@ services:
# See finlyticassets above: broker has no auth configured yet, do not enable.
#- MQTT__Username=${MQTT_USERNAME:-admin}
#- MQTT__Password=${MQTT_PASSWORD}
- MQTT__ClientId=finlytic_bot
- Alpaca__KeyId=${ALPACA_KEY_ID:-PK_PAPER_PLACEHOLDER_KEY}
- Alpaca__SecretKey=${ALPACA_SECRET_KEY:-SK_PAPER_PLACEHOLDER_SECRET}
- Alpaca__IsPaper=true
finlyticnotify:
image: finlyticnotify
build:
context: .
dockerfile: FinlyticNotify/Dockerfile
restart: unless-stopped
networks:
- postgres-network
environment:
- ConnectionStrings__DefaultConnection=Host=${DB_HOST:-OmniDB};Port=${DB_PORT:-5432};Database=finlytic_notify;Username=admin;Password=${DB_PASSWORD}
- MQTT__Host=${MQTT_HOST:-host.docker.internal}
- MQTT__Port=${MQTT_PORT:-4545}
- MQTT__ClientId=finlytic_notify
- Ntfy__BaseUrl=${NTFY_BASE_URL:-http://host.docker.internal:8080}
- Ntfy__TopicPrefix=finlytic
- Ntfy__BroadcastChannel=broadcast
- Ntfy__NewsChannel=news
- Ntfy__DefaultUsername=admin
- Ntfy__MinProposalScore=70.0
- Ntfy__NotifyOnProposals=true
- Ntfy__NotifyOnTradeUpdates=true
- Ntfy__NotifyOnBotTrades=true
- Ntfy__NotifyOnNews=true
finlyticbackend:
image: finlyticbackend
build:
context: .
dockerfile: FinlyticBackend/Dockerfile
# on-failure (bounded), NOT unless-stopped: this is the one service that
# deliberately throws at startup if JWT_SECRET_KEY / ADMIN_DEFAULT_PASSWORD
# are missing or too weak (see Program.cs fail-fast guards). An
# unless-stopped policy would crash-loop that misconfiguration forever,
# burning CPU/log volume while masking the real problem. A bounded
# on-failure still recovers from transient startup races (e.g. DB not
# yet reachable) but eventually settles into a visibly "Exited" container
# (`docker compose ps`) once the retries are exhausted, surfacing a
# persistent config error instead of hiding it.
restart: on-failure:5
ports:
- "5000:8080"
@@ -254,55 +266,6 @@ services:
volumes:
- ${FINLYTIC_DATA_ROOT:-C:/Users/larsh/Documents/docker/finlytic}/assets/index:/app/assets/index
- ${FINLYTIC_DATA_ROOT:-C:/Users/larsh/Documents/docker/finlytic}/assets/logos:/app/assets/logos
# Honest healthcheck: actually opens a TCP connection to the real Kestrel
# port and parses the real HTTP status line from the real GET /health
# endpoint (Program.cs, AllowAnonymous, no auth required). The final image
# (mcr.microsoft.com/dotnet/aspnet:10.0) has neither curl nor wget
# installed (verified) — installing one just for this would add an extra
# apt layer, so instead we use bash's built-in /dev/tcp (bash itself IS
# present in the base image, verified), invoked directly via exec form so
# it does not go through /bin/sh (which is dash on this image and does
# NOT support /dev/tcp).
healthcheck:
test:
- CMD
- bash
- -c
- >-
exec 3<>/dev/tcp/127.0.0.1/8080 &&
printf 'GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 &&
head -n1 <&3 | grep -q '200'
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# ──────────────────────────────────────────────────────────────────────────
# No HEALTHCHECK on the 8 worker services above (finlyticassets, finlyticnews,
# finlyticfundamentals, finlyticsentiment, finlytictechnicals, finlyticengine,
# finlyticsimulation, finlyticbot) — and this is deliberate, not an omission:
#
# - They are Microsoft.NET.Sdk.Worker projects and MUST NOT host an HTTP
# server (Rules.md §5), so there is no `GET /health`-style endpoint to
# probe, by design.
# - Docker already restarts/reports a dead PID 1 via the `restart` policy
# above without any HEALTHCHECK — a HEALTHCHECK only adds value if it
# can distinguish "process alive but broken" from "process alive and
# working", which requires touching something specific to the app.
# - The only things reachable from inside these containers without an
# app-level probe endpoint are the external DB/MQTT dependencies
# themselves (e.g. via bash's /dev/tcp, as used for finlyticbackend
# above). But a bare TCP-reachability check to OmniDB/MQTT tests the
# network path, not the worker — it would report "healthy" while the
# worker is deadlocked, and "unhealthy" during a legitimate external
# outage the worker's own retry logic is already handling. That is
# placebo/misleading in both directions, not an honest signal.
#
# Conclusion: no meaningful, non-cosmetic healthcheck is possible here
# without adding an HTTP endpoint (forbidden by Rules.md §5). Leaving
# HEALTHCHECK unset is the honest choice.
# ──────────────────────────────────────────────────────────────────────────
networks:
postgres-network:
external: true
+40
View File
@@ -0,0 +1,40 @@
services:
ntfy:
image: binwiederhier/ntfy:latest
container_name: finlytic-ntfy
command:
- serve
environment:
- NTFY_BASE_URL=http://localhost:8080
- NTFY_BEHIND_PROXY=false
- NTFY_CACHE_FILE=/var/cache/ntfy/cache.db
- NTFY_AUTH_DEFAULT_ACCESS=read-write
volumes:
- ntfy_cache:/var/cache/ntfy
ports:
- "8080:80"
restart: unless-stopped
finlytic-notify:
build:
context: .
dockerfile: FinlyticNotify/Dockerfile
container_name: finlytic-notify
environment:
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=finlytic;Username=postgres;Password=postgres
- Mqtt__BrokerHost=mosquitto
- Mqtt__BrokerPort=1883
- Ntfy__BaseUrl=http://ntfy:80
- Ntfy__TopicPrefix=finlytic
- Ntfy__BroadcastChannel=broadcast
- Ntfy__DefaultUsername=admin
- Ntfy__MinProposalScore=70.0
- Ntfy__NotifyOnProposals=true
- Ntfy__NotifyOnTradeUpdates=true
- Ntfy__NotifyOnBotTrades=true
depends_on:
- ntfy
restart: unless-stopped
volumes:
ntfy_cache: