Compare commits
6 Commits
2b7d59f40d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 567ddea46a | |||
| 2fe8cc0dca | |||
| d161efb370 | |||
| cb8a169043 | |||
| c5d7d359ba | |||
| 6a0f9af3f8 |
@@ -23,6 +23,7 @@ $images = @(
|
||||
"finlyticengine",
|
||||
"finlyticsimulation",
|
||||
"finlyticbot",
|
||||
"finlyticnotify",
|
||||
"finlyticbackend"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 & 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;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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
@@ -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 123–147)
|
||||
- **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 82–86)
|
||||
- **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 **8–15%** 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 1–3 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 |
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user